Matching Expectations
Es ist Sommer! Also ab in die Eisdiele und im besten Pseudo-Italienisch „Una Tschokkolatta per fawor!“ bestellen – doch was gibt es statt der leckeren braunen Schokoladenoffenbarung in einer knusprigen handgerollten Waffel? Eine rosa „Einhorn“-Eis-Scheußlichkeit mit Marshmallowklumpen in einem Pappbecher. Eine weitere schöne Urlaubserinnerung runiniert durch mangelhafte Unit-Tests. Moooment. Wie bitte? Nein, dies wird kein Bericht über meinen letzten Urlaub. Guckt mal da drüben. Heute möchte ich darüber schreiben, wie man Erwartungen in Unit-Tests abprüfen kann.
Eis!
Das Erlebnis aus der Eisdiele soll dabei als Beispiel dienen. Also modellieren wir unser Eis:
public class IceCream { private String flavor; private List pieces = Collections.emptyList(); private Color color; /* getters and setters, we are good little Beans programmers ;-) */ } public class Pieces { private String flavor; private Consistency consistency; /* getters and setters again */ } public enum Consistency { CRUNCHY, SOFT }
Dann brauchen wir noch ein Behältnis, damit die Kugel nicht durch die Finger flutscht:
public abstract class Container { private List scoops = new ArrayList<>(3); public List getScoops() { return scoops; } public void addScoops(IceCream ... scoops) { for (IceCream scoop : scoops) { this.scoops.add(scoop); } } /** for convenience **/ public Container with(IceCream ... scoops) { addScoops(scoops); return this; } } public class Cup extends Container { private Material material = Material.PLASTIC; /* get/set */ } public enum Material { PLASTIC, PAPER } public class Cone extends Container { private boolean handRolled; /* is/set */ }
… und schließlich den Auslöser für unser Trauma, das Trio Infernale der Eisverkäufer – je mit einem eigenen Verständnis, wie so ein Eis auszusehen hat:
public enum IceCreamParlor { Mario { @Override public Container order(String description) { if(description.toLowerCase().matches(".*o.*[ck].*ola.*")){ IceCream iceCream = new IceCream(); iceCream.setColor(new Color(135, 95, 75)); iceCream.setFlavor("chocolate"); Cone cone = new Cone(); cone.setHandRolled(true); return cone.with(iceCream); }else if(description.toLowerCase().matches(".*tri[ck]olo.*")){ IceCream pistacchio = new IceCream(); pistacchio.setColor(new Color(229, 255, 204)); pistacchio.setFlavor("pistacchio"); Pieces pistacchioPieces = new IceCream.Pieces(); pistacchioPieces.setConsistency(Consistency.CRUNCHY); pistacchioPieces.setFlavor("pistacchio"); pistacchio.setPieces(Collections.singletonList(pistacchioPieces)); IceCream lemon = new IceCream(); lemon.setColor(new Color(255, 255, 245)); lemon.setFlavor("lemon"); IceCream strawberry = new IceCream(); strawberry.setColor(new Color(255, 205, 224)); strawberry.setFlavor("strawberry"); Pieces strawberryPieces = new IceCream.Pieces(); strawberryPieces.setConsistency(Consistency.SOFT); strawberryPieces.setFlavor("strawberry"); strawberry.setPieces(Collections.singletonList(strawberryPieces)); Cup cup = new Cup(); cup.setMaterial(Material.PLASTIC); return cup.with(pistacchio,lemon,strawberry); }else{ Cone cone = new Cone(); cone.setHandRolled(true); return cone; } } }, Luigi { @Override public Container order(String description) { if(description.toLowerCase().matches(".*o.*[ck].*ola.*")){ IceCream iceCream = new IceCream(); iceCream.setColor(new Color(135, 95, 75)); iceCream.setFlavor("chocolate"); Cone cone = new Cone(); cone.setHandRolled(true); return cone.with(iceCream); }else if(description.toLowerCase().matches(".*tri[ck]olo.*")){ IceCream pistacchio = new IceCream(); pistacchio.setColor(new Color(229, 255, 204)); pistacchio.setFlavor("pistacchio"); Pieces pistacchioPieces = new IceCream.Pieces(); pistacchioPieces.setConsistency(Consistency.CRUNCHY); pistacchioPieces.setFlavor("pistacchio"); pistacchio.setPieces(Collections.singletonList(pistacchioPieces)); IceCream lemon = new IceCream(); lemon.setColor(new Color(255, 255, 0)); lemon.setFlavor("lemon"); IceCream strawberry = new IceCream(); strawberry.setColor(new Color(255, 205, 224)); strawberry.setFlavor("strawberry"); Cup cup = new Cup(); cup.setMaterial(Material.PAPER); return cup.with(pistacchio,lemon,strawberry); }else{ Cone cone = new Cone(); cone.setHandRolled(true); return cone; } } }, Peach { @Override public Container order(String description) { IceCream iceCream = new IceCream(); iceCream.setColor(new Color(255, 20, 150)); iceCream.setFlavor("fantastic"); Pieces pieces = new IceCream.Pieces(); pieces.setConsistency(Consistency.SOFT); pieces.setFlavor("Marshmallow"); iceCream.setPieces(Collections.singletonList(pieces)); Cone cone = new Cone(); cone.setHandRolled(true); return cone.with(iceCream); } }; abstract public Container order(String description); }
Verkostung
Dann wollen wir mal unsere Erwartungen formulieren:
public class IceCreamParlorTest { /* Hier einfach den Verkäufer wechseln. Für parametrisierte Tests ist Platz in einem anderen Blogpost. */ IceCreamParlor vendor = IceCreamParlor.Peach; @Test public void orderOneScoopOfChoclate() { Container order = vendor.order("Una Tschokkolatta per fawor!"); assertNotNull(order); // Unsere Bestellung wurde erhört wir kriegen zumindest schon mal irgend was assertTrue(order instanceof Cone); // in einer Waffel, assertTrue(((Cone) order).isHandRolled()); // die frisch gebacken und von Hand gerollt wurde List scoops = order.getScoops(); assertNotNull(scoops); // und Kugeln enthält; assertEquals(1, scoops.size()); // eine, um genau zu sein. IceCream scoop1 = scoops.get(0); // Diese erste Kugel assertEquals("chocolate", scoop1.getFlavor()); // schmeckt nach Schokolade Color color = scoop1.getColor(); // und sieht auch braun aus. Wie soll man das bitte testen? Schön ist was anderes... assertTrue(color.getR() > 128 && color.getG() > 32 && color.getR() > color.getG() && color.getG() >= color.getB()); assertTrue(scoop1.getPieces().isEmpty()); // Und ganz wichtig: keine Stückchen in unserer Eis_krem_ } }
Ein netter kleiner Unittest. Aber es fällt schwer zu sehen, was genau wir hier testen – zumindest mir, und ich hab’s gerade erst geschrieben. Was hat uns Onkel Bob zu Sachen gesagt, die wir gerade geschrieben haben? Schaut, ob ein wenig Refactoring sie verbessern kann! Also erst mal raus mit diesem grausigen Farbtest in eine eigene Methode – und wenn wir schon mal dabei sind könnten auch die Prüfungen für Behälter und Stückchen etwas eloquenter ausfallen:
@Test public void orderOneScoopOfChoclate() { Container order = vendor.order("Una Tschokkolatta per fawor!"); assertHandrolledCone(order); List scoops = order.getScoops(); assertNotNull(scoops); assertEquals(1, scoops.size()); IceCream scoop1 = scoops.get(0); assertEquals("chocolate", scoop1.getFlavor()); assertBrown(scoop1.getColor()); assertNoPieces(scoop1); } private void assertHandrolledCone(Container order) { assertNotNull(order); assertTrue(order instanceof Cone); assertTrue(((Cone) order).isHandRolled()); } private void assertBrown(Color color) { assertTrue(color.getR() > 128 && color.getG() > 32 && color.getR() > color.getG() && color.getG() >= color.getB()); } private void assertNoPieces(IceCream scoop) { List pieces = scoop.getPieces(); assertTrue(pieces.isEmpty()); }
So. Gleich viel besser. Mal schaun, wie’s mit einer größeren Bestellung läuft – dem berühmten „Tricolore“-Becher in den italienischen Nationalfarben Grün, Weiß und Rot, repräsentiert durch Pistazieneis mit kleinen knusprigen Pistaziensplittern, einer schön sauren Kugel Zitroneneis (keine fancy Zitronenschalenzesten bitte) und Erdbeereis aus echten Erdbeeren, so dass noch ein paar weiche Erdbeerstückchen darin sind. Und nachdem wir unseren Kindern nicht zutrauen, drei Kugel auf einer Waffel zu balanzieren, ohne dass sie schmelzen oder auf T-Shirt oder Fußboden landen, bitte in einem stabilen Plastikbecher, der weder zerdrückt wird noch durchweicht. Sch*** auf die Umwelt, wir schreiben hier Unittests…
@Test public void orderTricolore() { Container order = vendor.order("La Tricolore, bitte"); assertPlasticCup(order); List scoops = order.getScoops(); assertNotNull(scoops); assertEquals(3, scoops.size()); IceCream scoop1 = scoops.get(0); assertEquals("pistacchio", scoop1.getFlavor()); assertGreen(scoop1.getColor()); List pieces = scoop1.getPieces(); assertFalse(pieces.isEmpty()); assertEquals(Consistency.CRUNCHY, pieces.get(0).getConsistency()); assertEquals("pistacchio", pieces.get(0).getFlavor()); IceCream scoop2 = scoops.get(1); assertEquals("lemon", scoop2.getFlavor()); assertWhite(scoop2.getColor()); assertNoPieces(scoop2); IceCream scoop3 = scoops.get(2); assertEquals("strawberry", scoop3.getFlavor()); assertPink(scoop3.getColor()); List pieces3 = scoop3.getPieces(); assertFalse(pieces3.isEmpty()); assertEquals(Consistency.SOFT, pieces3.get(0).getConsistency()); assertEquals("strawberry", pieces3.get(0).getFlavor()); }
Da wiederholt sich aber ganz schön viel Code. Kann mir bitte jemand den Unterschied zwischen den Tests der einzelnen Kugeln zeigen? Alles was ich hier sehe ist Boilerplate-Code. Was sagst du, Bob?
@Test public void orderTricolore() { Container order = vendor.order("La Tricolore, bitte"); assertPlasticCup(order); List scoops = order.getScoops(); assertNotNull(scoops); assertEquals(3, scoops.size()); IceCream scoop1 = assertScoop(scoops, 0, "pistacchio", Consistency.CRUNCHY, "pistacchio"); assertGreen(scoop1.getColor()); IceCream scoop2 = assertScoop(scoops, 1, "lemon", null, null); assertWhite(scoop2.getColor()); IceCream scoop3 = assertScoop(scoops, 2, "strawberry", Consistency.SOFT, "strawberry"); assertPink(scoop3.getColor()); } private IceCream assertScoop(List scoops, int index, String flavorIceCream, Consistency consistency, String flavorPieces) { IceCream scoop = scoops.get(index); assertEquals(flavorIceCream, scoop.getFlavor()); List pieces = scoop.getPieces(); if (consistency != null) { assertFalse(pieces.isEmpty()); assertEquals(consistency, pieces.get(0).getConsistency()); assertEquals(flavorPieces, pieces.get(0).getFlavor()); } else { assertNoPieces(scoop); } return scoop; }
Besser. Jetzt sehe ich die Unterschiede viel klarer. Nur die Farbprüfung musste außen vor bleiben, weil das jeweils ein anderer Assert ist und wir in unserer assertScoop
keine Switch-Case-Orgie starten wollen. Und die Überpfüfungslogik kann man schließlich nicht herumreichen… na gut. Mit Java 8 und Lambdas geht das sogar halbwegs lesbar:
@Test public void orderTricolore() { Container order = vendor.order("La Tricolore, bitte"); assertPlasticCup(order); List scoops = order.getScoops(); assertNotNull(scoops); assertEquals(3, scoops.size()); assertScoop(scoops, 0, "pistacchio", Consistency.CRUNCHY, "pistacchio", this::assertGreen); assertScoop(scoops, 1, "lemon", null, null, this::assertWhite); assertScoop(scoops, 2, "strawberry", Consistency.SOFT, "strawberry", this::assertPink); } private void assertScoop(List scoops, int index, String flavorIceCream, Consistency consistency, String flavorPieces, Consumer colorAssert) { IceCream scoop = scoops.get(index); assertEquals(flavorIceCream, scoop.getFlavor()); List pieces = scoop.getPieces(); if (consistency != null) { assertFalse(pieces.isEmpty()); assertEquals(consistency, pieces.get(0).getConsistency()); assertEquals(flavorPieces, pieces.get(0).getFlavor()); } else { assertNoPieces(scoop); } colorAssert.accept(scoop.getColor()); }
Und wenn es nur um Eis ginge würde ich hier vermutlich auch aufhören und mich darauf konzentrieren der Prinzessin beizubringen, dass nicht jeder fluffiges Einhorneis möchte. Oder ein paar BuilderFactoryProvider schreiben um das servieren der Eisbecher zu vereinfachen. Aber ich wollte was über Tests erzählen und du hast noch nicht weggeklickt, also führen wir den Test mal aus:
java.lang.AssertionError at org.junit.Assert.fail(Assert.java:86) at org.junit.Assert.assertTrue(Assert.java:41) at org.junit.Assert.assertTrue(Assert.java:52) at de.faktorzehn.blog.matching.demo.IceCreamParlorTest.assertPlasticCup(IceCreamParlorTest.java:113) at de.faktorzehn.blog.matching.demo.IceCreamParlorTest.orderTricolore[...]
Was genau lief hier verkehrt? Keine Ahnung ohne in Zeile 113 der Testklasse nachzusehen:
assertTrue(order instanceof Cup);
Wir können dem Assert noch einen Text für den Fehlerfall mitgeben,
assertTrue("must be a cup", order instanceof Cup);
java.lang.AssertionError: must be a cup at org.junit.Assert.fail(Assert.java:88) at org.junit.Assert.assertTrue(Assert.java:41) at de.faktorzehn.blog.matching.demo.IceCreamParlorTest.assertPlasticCup(IceCreamParlorTest.java:114) at de.faktorzehn.blog.matching.demo.IceCreamParlorTest.orderTricolore[...]
aber wer will schon immer wieder das gleiche in Prosa und Code schreiben (geschweigedenn bei einem Refactoring anpassen)? Sollte ich neben meinen selbst geschreibenen Asserts noch einen Pool von String-Konstanten oder -Buildern anlegen? Oder könnte ich vielleicht das Wissen, wie etwas geprüft wird und wie eine Abweichung davon dargestellt wird in ein Objekt verpacken? Lasst uns dieses Objekt Matcher
nennen. Und bevor wir es schreiben, mal kurz schauen, ob das vielleicht schon jemand für uns gemacht hat. Tatsache. JUnit (genauer gesagt das damit ausgelieferte Hamcrest) bietet Matcher und passende Basisklassen für eigene Implementierungen. Also frisch ans Werk und ein paar eigene Matcher geschrieben:
Matcher
Erst mal ein Matcher für Farben, der die kruden R/G/B-Vergleiche hinter netten Labels versteckt:
public static class ColorMatcher extends BaseMatcher { private final String colorDescription; private final Predicate colorMatch; public ColorMatcher(String colorDescription, Predicate colorMatch) { super(); this.colorDescription = colorDescription; this.colorMatch = colorMatch; } @Override public boolean matches(Object arg0) { return arg0 instanceof Color && colorMatch.test((Color) arg0); } @Override public void describeTo(Description description) { description.appendText(colorDescription); } };
Und noch ein paar Konstanten für unsere Farben:
public static class ColorMatcher extends BaseMatcher { private final String colorDescription; private final Predicate colorMatch; public ColorMatcher(String colorDescription, Predicate colorMatch) { super(); this.colorDescription = colorDescription; this.colorMatch = colorMatch; } @Override public boolean matches(Object arg0) { return arg0 instanceof Color && colorMatch.test((Color) arg0); } @Override public void describeTo(Description description) { description.appendText(colorDescription); } };
Natürlich können wir diesen Matcher in einem anderen Matcher wiederverwenden und genau wie zuvor die Lambdas durchreichen.
public static class ScoopMatcher extends BaseMatcher { private final ColorMatcher colorMatcher; private final String flavorIceCream; private final Consistency consistency; private final String flavorPieces; public ScoopMatcher(ColorMatcher colorMatcher, String flavorIceCream, Consistency consistency, String flavorPieces) { super(); this.colorMatcher = colorMatcher; this.flavorIceCream = flavorIceCream; this.consistency = consistency; this.flavorPieces = flavorPieces; } @Override public boolean matches(Object item) { if (item instanceof IceCream) { IceCream iceCream = ((IceCream) item); return colorMatcher.matches(iceCream.getColor()) && iceCream.getFlavor().equals(flavorIceCream) && consistency == null ? iceCream.getPieces().isEmpty() : iceCream.getPieces().get(0).getConsistency() == consistency && iceCream.getPieces().get(0).getFlavor().equals(flavorPieces); } return false; } @Override public void describeTo(Description description) { colorMatcher.describeTo(description); description.appendText(" " + flavorIceCream + " ice cream with " + (consistency == null ? "no" : consistency.toString().toLowerCase() + " " + flavorPieces) + " pieces"); } }
Bei der Verwendung von Matchern ist es üblich, die Assertions im Stil assertThat(x, is(matcher))
zu schreiben:
@Test public void orderTricolore() { Container order = vendor.order("La Tricolore, bitte"); assertThat(order, is(instanceOf(Cup.class))); assertThat(((Cup) order).getMaterial(), is(Material.PLASTIC)); List scoops = order.getScoops(); assertThat(scoops, is(not(nullValue()))); assertThat(scoops.size(), is(3)); assertThat(scoops.get(0), new ScoopMatcher(ColorMatcher.GREEN, "pistacchio", Consistency.CRUNCHY, "pistacchio")); assertThat(scoops.get(1), new ScoopMatcher(ColorMatcher.WHITE, "lemon", null, null)); assertThat(scoops.get(2), new ScoopMatcher(ColorMatcher.PINK, "strawberry", Consistency.SOFT, "strawberry")); }
Natürlich können wir auch einen Matcher für die Waffel schreiben;
public static class ConeMatcher extends BaseMatcher { private boolean handRolled; public ConeMatcher(boolean handRolled) { super(); this.handRolled = handRolled; } @Override public boolean matches(Object item) { return item instanceof Cone && ((Cone) item).isHandRolled() == handRolled; } @Override public void describeTo(Description description) { description.appendText((handRolled ? "a handrolled" : "a") + " cone"); } } @Test public void orderOneScoopOfChoclate() { Container order = vendor.order("Una Tschokkolatta per fawor!"); assertThat(order, is(new ConeMatcher(true))); List scoops = order.getScoops(); assertThat(scoops, is(not(nullValue()))); assertThat(scoops.size(), is(1)); assertThat(scoops.get(0), new ScoopMatcher(ColorMatcher.BROWN, "chocolate", null, null)); }
…aber der Testcode könnte besser aussehen. Was soll das true
überhaupt bedeuten? Hamcrest’s CoreMatchers
bietet viele statische Methoden zur Erzeugeung passender Matcher an, das sollten wir auch tun:
public static ConeMatcher aHandrolledCone() { return new ConeMatcher(true); } @Test public void orderOneScoopOfChoclate() { Container order = vendor.order("Una Tschokkolatta per fawor!"); assertThat(order, is(aHandrolledCone())); List scoops = order.getScoops(); assertThat(scoops, is(not(nullValue()))); assertThat(scoops.size(), is(1)); assertThat(scoops.get(0), new ScoopMatcher(ColorMatcher.BROWN, "chocolate", null, null)); }
Nachem jetzt unsere Matcher mit einer lesbaren Beschreibung glänzen sollten wir auch unseren Objekten eine oredentliche toString
-Methode verpassen, um statt so was
java.lang.AssertionError: Expected: brown chocolate ice cream with no pieces but: was <de.faktorzehn.blog.matching.model.IceCream@311d617d> at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20) at org.junit.Assert.assertThat(Assert.java:956) at org.junit.Assert.assertThat(Assert.java:923) at de.faktorzehn.blog.matching.demo.IceCreamParlorTest.orderOneScoopOfChoclate[...]
das zu erhalten:
java.lang.AssertionError: Expected: brown chocolate ice cream with no pieces but: was Color[255,175,205] fantastic ice cream with [SOFT Marshmallow] pieces at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20) at org.junit.Assert.assertThat(Assert.java:956) at org.junit.Assert.assertThat(Assert.java:923) at de.faktorzehn.blog.matching.demo.IceCreamParlorTest.orderOneScoopOfChoclate[...]
Aber warum übergeben wir unserem ScoopMatcher
immer noch zwei mal null
? Oder im Fall der „Tricolore“ – wer weiß beim nächsten Blick auf den Code noch, warum hier zwei mal "strawberry"
übergeben wird? War der erste oder zweite String der Geschmack der Stückchen? Können wir nich einfach sprechende Matcher für alle Eigenschaften unseres Eisbechers schreiben und die kombinieren? CoreMatchers.allOf(Matcher<? super T>...)
macht genau das – es kombiniert alle Matcher zum gleichen Objekt und prüft, ob alle zutreffen. Aber wir wollen eigentlich nicht jedes Mal den kompletten Eisbecher Matchen, sondern einzelne Eigenschaften. Den Code, um diese Eigenschaften vom Ganzen abzufragen und mit einem passenden Matcher zu prüfen können wir in eine eigene Basisklasse auslagern:
/** * A {@link Matcher Matcher<T>} that uses a wrapped {@link Matcher * Matcher<P>} on a property of type <P> of the matched object of * type <T>. * * @param <T> the type of the matched object * @param <P> the type of the object's property matched by the wrapped matcher */ public class PropertyMatcher<T, P> extends BaseMatcher<T> { private Function<T, P> propertyGetter; private Matcher<P> propertyMatcher; private String propertyDescription; public PropertyMatcher(Function<T, P> propertyGetter, String propertyDescription, Matcher<P> propertyMatcher) { this.propertyGetter = propertyGetter; this.propertyDescription = propertyDescription; this.propertyMatcher = propertyMatcher; } @Override public boolean matches(Object item) { P property = getNullSafe(item); return propertyMatcher.matches(property); } @SuppressWarnings("unchecked") public P getNullSafe(Object item) { return item == null ? null : propertyGetter.apply((T) item); } @Override public void describeTo(Description description) { description.appendText(propertyDescription + ' '); propertyMatcher.describeTo(description); } @Override public void describeMismatch(Object item, Description description) { P property = getNullSafe(item); super.describeMismatch(property, description); if (propertyMatcher instanceof PropertyMatcher) { description.appendText("\n because "); ((PropertyMatcher<?, ?>) propertyMatcher).getPropertyMatcher().describeMismatch(item, description); } } public Matcher<P> getPropertyMatcher() { return propertyMatcher; } }
…und damit Farbe und Geschmack prüfen:
@Test public void orderOneScoopOfChoclate() { Container order = vendor.order("Una Tschokkolatta per fawor!"); assertThat(order, is(aHandrolledCone())); List scoops = order.getScoops(); assertThat(scoops, is(not(nullValue()))); assertThat(scoops.size(), is(1)); assertThat(scoops.get(0), org.hamcrest.CoreMatchers.allOf( new PropertyMatcher<IceCream, Color>(IceCream::getColor, "colored ", ColorMatcher.BROWN), new PropertyMatcher<IceCream, String>(IceCream::getFlavor, "flavored", equalTo("chocolate")))); }
Jetzt noch diese Matcher in eigenen Methoden erzeugen:
private PropertyMatcher<IceCream, String> flavored(String flavor) { return new PropertyMatcher<IceCream, String>(IceCream::getFlavor, "flavored", equalTo(flavor)); } private PropertyMatcher<IceCream, Color> colored(ColorMatcher color) { return new PropertyMatcher<IceCream, Color>(IceCream::getColor, "colored ", color); } private PropertyMatcher<IceCream, Boolean> withOutPieces() { return new PropertyMatcher<IceCream, Boolean>(i -> i.getPieces().isEmpty(), "without pieces", is(true)); } @Test public void orderOneScoopOfChoclate() { Container order = vendor.order("Una Tschokkolatta per fawor!"); assertThat(order, is(aHandrolledCone())); List scoops = order.getScoops(); assertThat(scoops, is(not(nullValue()))); assertThat(scoops.size(), is(1)); assertThat(scoops.get(0), is(allOf(colored(ColorMatcher.BROWN), flavored("chocolate"), withOutPieces()))); }
Wir können auch einen Matcher für die Stückechen zusammenstellen:
private PropertyMatcher<IceCream, Pieces> withPieces(Consistency consistency, String flavor) { return new PropertyMatcher<IceCream, Pieces>(i->i.getPieces().get(0), "with pieces", allOf(new PropertyMatcher<Pieces, Consistency>(Pieces::getConsistency, "consistency", equalTo(consistency)), new PropertyMatcher<Pieces, String>(Pieces::getFlavor, "flavored", equalTo(flavor)))); } @Test public void orderTricolore() { Container order = vendor.order("La Tricolore, bitte"); assertThat(order, is(aCupOf(Material.PLASTIC))); List scoops = order.getScoops(); assertThat(scoops, is(not(nullValue()))); assertThat(scoops.size(), is(3)); assertThat(scoops.get(0), is(allOf(colored(ColorMatcher.GREEN), flavored("pistacchio"), withPieces(Consistency.CRUNCHY, "pistacchio")))); assertThat(scoops.get(1), is(allOf(colored(ColorMatcher.WHITE), flavored("lemon"), withOutPieces()))); assertThat(scoops.get(2), is(allOf(colored(ColorMatcher.PINK), flavored("strawberry"), withPieces(Consistency.SOFT, "strawberry")))); }
Ja, auch die Matcher für Konsistenz und Geschmack der Stückchen könnten noch mit eingenen Methoden erzeugt und reingereicht werden, aber ob das die Lesbarkeit weiter verbessert bezweifle ich in diesem Fall.
Gehen wir noch einen Schritt weiter und verpacken die Listen-Prüfungen so, dass wir die Kugel-Matcher direkt auflisten:
/** just renamed, so that it reads better ./ @SafeVarargs private static Matcher aScoopOf(Matcher ... matchers){ return allOf(matchers); } @SafeVarargs public static Matcher<List> are(Matcher ... scoopMatchers) { @SuppressWarnings("unchecked") Matcher<List<? extends IceCream>>[] matchers = new Matcher[scoopMatchers.length + 1]; matchers[0] = new PropertyMatcher<>(List::size, "Number of Scoops", is(scoopMatchers.length)); for (int i = 1; i < matchers.length; i++) { final int j = i - 1; matchers[i] = new PropertyMatcher<>(l -> l.size() > j ? l.get(j) : null, "Scoop[" + i + "]", scoopMatchers[j]); } return allOf(matchers); } @Test public void orderTricolore() { Container order = vendor.order("La Tricolore, bitte"); assertThat(order, is(aCupOf(Material.PLASTIC))); assertThat(order.getScoops(), are( aScoopOf(colored(ColorMatcher.GREEN), flavored("pistacchio"), withPieces(Consistency.CRUNCHY, "pistacchio")), aScoopOf(colored(ColorMatcher.WHITE), flavored("lemon"), withOutPieces()), aScoopOf(colored(ColorMatcher.PINK), flavored("strawberry"), withPieces(Consistency.SOFT, "strawberry")))); }
Fast fertig. Jetzt noch die Matcher für Behälter und Inhalt vereinen und das ganze liest sich wie ein Satz:
@SafeVarargs public static PropertyMatcher<Container, List> with(Matcher ... scoopMatchers){ return new PropertyMatcher<Container, List>(Container::getScoops, "with ", are(scoopMatchers)); } @Test public void orderTricolore() { Container order = vendor.order("La Tricolore, bitte"); assertThat(order, is(allOf( aCupOf(Material.PLASTIC), with( aScoopOf(colored(ColorMatcher.GREEN), flavored("pistacchio"), withPieces(Consistency.CRUNCHY, "pistacchio")), aScoopOf(colored(ColorMatcher.WHITE), flavored("lemon"), withOutPieces()), aScoopOf(colored(ColorMatcher.PINK), flavored("strawberry"), withPieces(Consistency.SOFT, "strawberry")) )))); } @Test public void orderOneScoopOfChoclate() { Container order = vendor.order("Una Tschokkolatta per fawor!"); assertThat(order, is(allOf( aHandrolledCone(), with(aScoopOf(colored(ColorMatcher.BROWN), flavored("chocolate"), withOutPieces()))))); }
Jetzt sehen wir auch endlich deutlich, was Luigi falsch gemacht hat:
java.lang.AssertionError: Expected: is (a plastic cup and with (Number of Scoops is <3> and Scoop[1] (colored green and flavored "pistacchio" and with pieces (consistency <CRUNCHY> and flavored "pistacchio")) and Scoop[2] (colored white and flavored "lemon" and without pieces is <true>) and Scoop[3] (colored pink and flavored "strawberry" and with pieces (consistency <SOFT> and flavored "strawberry")))) but: a plastic cup was <a paper cup> at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20) at org.junit.Assert.assertThat(Assert.java:956) at org.junit.Assert.assertThat(Assert.java:923) at de.faktorzehn.blog.matching.demo.IceCreamParlorTest.orderTricolore[...]
Das ist leider nicht die ganze Wahrheit, denn Luigis Zitroneneis ist auch zu gelb, aber Hamcrest’s allOf
beendet die Prüfung nach dem ersten Fehler, so dass man potentiell von Fehler zu Fehler stolpert statt alle auf ein Mal zu sehen. Hier könnte man sich einen eigenen kombinierenden Matcher schreiben, der alle Kinder immer ausführt, die Ergebnisse in der Methode matches
sammelt und in describeMismatch
ausgibt.
Zusammenfassung: (Wann) sollte ich Matcher verwenden?
Matcher helfen bei zwei Aspekten:
- Wiederverwendung von Testcode
- Lesbarkeit von Tests
und das mit gut lesbaren Fehlermeldungen. Es ist etwas mehr Aufwand als für den ersten Testfall ein paar Asserts runter zu hacken und diese dann per Copy’n’Paste wieder zu verwenden, aber wenn man die Tests nach einer Weile wieder bearbeiten und verstehen muss macht sich der Aufwand bezahlt.
Und natürlich ist es mindestens genau so wichtig das Test-Setup lesbar zu gestalten. Wenn man auf einen Blick sehen kann, was in einem Test geschieht und wie er sich von seinen Nachbarn unterscheidet kann man mehr Zeit zur Behebung der Eis-Bugs aufbringen statt sich durch alten Spaghetti-Test-Code zu wühlen.