Skip to content

Instantly share code, notes, and snippets.

@jbrains
Created October 13, 2018 19:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jbrains/a44701162b41c423e312792f94eee37e to your computer and use it in GitHub Desktop.
Save jbrains/a44701162b41c423e312792f94eee37e to your computer and use it in GitHub Desktop.
An example of using a custom Hamcrest matcher to write tests. What do you think of this style?
package ca.jbrains.java.test;
import ca.jbrains.java.ReaderBasedTextSource;
import io.vavr.collection.List;
import io.vavr.collection.Stream;
import org.hamcrest.Description;
import org.hamcrest.Factory;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Assert;
import org.junit.Test;
import java.io.StringReader;
import static ca.jbrains.java.test.LearnReadingLinesTest.TextParsesIntoLines.parsesInto;
public class LearnReadingLinesTest {
@Test
public void readOneLineWithoutALineSeparator() throws Exception {
Assert.assertThat(
"this is one line of text without a line separator.",
parsesInto(List.of(
"this is one line of text without a line separator.")));
}
@Test
public void readOneLineFromABlobOfTextEndingWithALineSeparator() throws Exception {
Assert.assertThat(
endWithLineSeparator("this is one line of text ending in a line separator."),
parsesInto(List.of(
"this is one line of text ending in a line separator.")));
}
@Test
public void readMultipleLinesEndingWithSeveralEmptyLines() throws Exception {
Assert.assertThat(new StringBuilder()
.append(endWithLineSeparator("a non-empty line of text"))
.append(endWithLineSeparator("a non-empty line of text"))
.append(endWithLineSeparator("a non-empty line of text"))
.append(endWithLineSeparator(""))
.append(endWithLineSeparator(""))
.append(endWithLineSeparator(""))
.append(endWithLineSeparator(""))
.toString(),
parsesInto(List.of(
"a non-empty line of text",
"a non-empty line of text",
"a non-empty line of text",
"",
"",
"",
"")));
}
@Test
public void readNothing() throws Exception {
Assert.assertThat("", parsesInto(List.empty()));
}
@Test
public void readOnlyOneEmptyLine() throws Exception {
Assert.assertThat(System.lineSeparator(), parsesInto(List.of("")));
}
// REFACTOR Maybe move to a generic text library?
public static String endWithLineSeparator(String text) {
return String.format("%s%s", text, System.lineSeparator());
}
/**
* Matches multiline text as lines, where lines are separated
* by {@code System.lineSeparator()}.
*/
public static class TextParsesIntoLines extends TypeSafeMatcher<String> {
private List<String> expectedLines;
public TextParsesIntoLines(List<String> expectedLines) {
this.expectedLines = expectedLines;
}
@Override
protected boolean matchesSafely(String text) {
return tokenizeAsLines(text).equals(expectedLines);
}
private List<String> tokenizeAsLines(String text) {
ReaderBasedTextSource textSource = new ReaderBasedTextSource(new StringReader(text));
Stream<String> streamOfLines = textSource.parseIntoLines();
return List.ofAll(streamOfLines);
}
@Override
protected void describeMismatchSafely(String text, Description mismatchDescription) {
mismatchDescription.appendText("instead parses into lines ")
.appendValueList("[", ",", "]", tokenizeAsLines(text));
}
@Override
public void describeTo(Description description) {
description.appendText("parses into lines ")
.appendValueList("[", ",", "]", expectedLines);
}
@Factory
public static Matcher<String> parsesInto(List<String> expectedLines) {
return new TextParsesIntoLines(expectedLines);
}
}
}
@dhemery
Copy link

dhemery commented Oct 13, 2018

In general, I quite like writing custom matchers make my tests more expressive or diagnosable.

If the intent of these tests is to test the ReaderBasedTextSource, I don't like that the parsesInto() matcher hides some of the important aspects of the responsibility. While it emphasizes the desired relationship between inputs and outputs, it hides both the agent to which you've assigned the responsibility and the means of invoking the responsibility (parseIntoLines()).

If the intent of the tests is something else, I'd likely have a different opinion.

@rubberduck203
Copy link

rubberduck203 commented Oct 13, 2018

I like the way the tests read, but I don’t like that I don’t see the code under test being called. I’m assuming the CUT is being called from inside of the matcher and that’s weird. We can’t get feedback about the API that way. Unless the fact that you felt the need to abstract away calling the CUT is itself a comment on the API design.

@jbrains
Copy link
Author

jbrains commented Oct 14, 2018

@dhemery @rubberduck203 I find one aspect of ReaderBasedTextSource that I believe doesn't fit the test: parseIntoLines() returns a Stream, but we can't compare Streams directly for equality, as far as I know, so I want Lists. Now we end up combining two bits of behavior in the matcher: invoking the production code and adapting the output to something test-friendly. I would prefer to separate those things.

I suppose I could compose the matcher with something that runs the production code in order to call that out better. I might play around with that a little.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment