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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | publicclassIceCream{ privateStringflavor; privateList pieces=Collections.emptyList(); privateColor color; /* getters and setters, we are good little Beans programmers 😉 */ } publicclassPieces{ privateStringflavor; privateConsistency consistency; /* getters and setters again */ } publicenumConsistency{ CRUNCHY,SOFT } |
Dann brauchen wir noch ein Behältnis, damit die Kugel nicht durch die Finger flutscht:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | publicabstractclassContainer{ privateList scoops=newArrayList<>(3); publicList getScoops(){ returnscoops; } publicvoidaddScoops(IceCream...scoops){ for(IceCream scoop:scoops){ this.scoops.add(scoop); } } /** for convenience **/ publicContainer with(IceCream...scoops){ addScoops(scoops); returnthis; } } publicclassCupextendsContainer{ privateMaterial material=Material.PLASTIC; /* get/set */ } publicenumMaterial{ PLASTIC,PAPER } publicclassConeextendsContainer{ privatebooleanhandRolled; /* 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | publicenumIceCreamParlor{ Mario{ @Override publicContainer order(Stringdescription){ if(description.toLowerCase().matches(„.*o.*[ck].*ola.*“)){ IceCream iceCream=newIceCream(); iceCream.setColor(newColor(135,95,75)); iceCream.setFlavor(„chocolate“); Cone cone=newCone(); cone.setHandRolled(true); returncone.with(iceCream); }elseif(description.toLowerCase().matches(„.*tri[ck]olo.*“)){ IceCream pistacchio=newIceCream(); pistacchio.setColor(newColor(229,255,204)); pistacchio.setFlavor(„pistacchio“); Pieces pistacchioPieces=newIceCream.Pieces(); pistacchioPieces.setConsistency(Consistency.CRUNCHY); pistacchioPieces.setFlavor(„pistacchio“); pistacchio.setPieces(Collections.singletonList(pistacchioPieces)); IceCream lemon=newIceCream(); lemon.setColor(newColor(255,255,245)); lemon.setFlavor(„lemon“); IceCream strawberry=newIceCream(); strawberry.setColor(newColor(255,205,224)); strawberry.setFlavor(„strawberry“); Pieces strawberryPieces=newIceCream.Pieces(); strawberryPieces.setConsistency(Consistency.SOFT); strawberryPieces.setFlavor(„strawberry“); strawberry.setPieces(Collections.singletonList(strawberryPieces)); Cup cup=newCup(); cup.setMaterial(Material.PLASTIC); returncup.with(pistacchio,lemon,strawberry); }else{ Cone cone=newCone(); cone.setHandRolled(true); returncone; } } },Luigi{ @Override publicContainer order(Stringdescription){ if(description.toLowerCase().matches(„.*o.*[ck].*ola.*“)){ IceCream iceCream=newIceCream(); iceCream.setColor(newColor(135,95,75)); iceCream.setFlavor(„chocolate“); Cone cone=newCone(); cone.setHandRolled(true); returncone.with(iceCream); }elseif(description.toLowerCase().matches(„.*tri[ck]olo.*“)){ IceCream pistacchio=newIceCream(); pistacchio.setColor(newColor(229,255,204)); pistacchio.setFlavor(„pistacchio“); Pieces pistacchioPieces=newIceCream.Pieces(); pistacchioPieces.setConsistency(Consistency.CRUNCHY); pistacchioPieces.setFlavor(„pistacchio“); pistacchio.setPieces(Collections.singletonList(pistacchioPieces)); IceCream lemon=newIceCream(); lemon.setColor(newColor(255,255,0)); lemon.setFlavor(„lemon“); IceCream strawberry=newIceCream(); strawberry.setColor(newColor(255,205,224)); strawberry.setFlavor(„strawberry“); Cup cup=newCup(); cup.setMaterial(Material.PAPER); returncup.with(pistacchio,lemon,strawberry); }else{ Cone cone=newCone(); cone.setHandRolled(true); returncone; } } },Peach{ @Override publicContainer order(Stringdescription){ IceCream iceCream=newIceCream(); iceCream.setColor(newColor(255,20,150)); iceCream.setFlavor(„fantastic“); Pieces pieces=newIceCream.Pieces(); pieces.setConsistency(Consistency.SOFT); pieces.setFlavor(„Marshmallow“); iceCream.setPieces(Collections.singletonList(pieces)); Cone cone=newCone(); cone.setHandRolled(true); returncone.with(iceCream); } }; abstractpublicContainer order(Stringdescription); } |
Verkostung
Dann wollen wir mal unsere Erwartungen formulieren:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | publicclassIceCreamParlorTest{ /* Hier einfach den Verkäufer wechseln. Für parametrisierte Tests ist Platz in einem anderen Blogpost. */ IceCreamParlor vendor=IceCreamParlor.Peach; @Test publicvoidorderOneScoopOfChoclate(){ Container order=vendor.order(„Una Tschokkolatta per fawor!“); assertNotNull(order);// Unsere Bestellung wurde erhört wir kriegen zumindest schon mal irgend was assertTrue(order instanceofCone);// 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @Test publicvoidorderOneScoopOfChoclate(){ 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); } privatevoidassertHandrolledCone(Container order){ assertNotNull(order); assertTrue(order instanceofCone); assertTrue(((Cone)order).isHandRolled()); } privatevoidassertBrown(Color color){ assertTrue(color.getR()>128&&color.getG()>32&&color.getR()>color.getG()&&color.getG()>=color.getB()); } privatevoidassertNoPieces(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…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | @Test publicvoidorderTricolore(){ 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?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | @Test publicvoidorderTricolore(){ 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()); } privateIceCream assertScoop(List scoops,intindex,StringflavorIceCream,Consistency consistency, StringflavorPieces){ 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); } returnscoop; } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | @Test publicvoidorderTricolore(){ 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); } privatevoidassertScoop(List scoops,intindex,StringflavorIceCream,Consistency consistency, StringflavorPieces,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:
1 2 3 4 5 6 | 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:
1 | assertTrue(order instanceofCup); |
Wir können dem Assert noch einen Text für den Fehlerfall mitgeben,
1 | assertTrue(„must be a cup“,order instanceofCup); |
1 2 3 4 5 | java.lang.AssertionError:must beacup 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | publicstaticclassColorMatcherextendsBaseMatcher{ privatefinalStringcolorDescription; privatefinalPredicate colorMatch; publicColorMatcher(StringcolorDescription,Predicate colorMatch){ super(); this.colorDescription=colorDescription; this.colorMatch=colorMatch; } @Override publicbooleanmatches(Objectarg0){ returnarg0 instanceofColor&&colorMatch.test((Color)arg0); } @Override publicvoiddescribeTo(Description description){ description.appendText(colorDescription); } }; |
Und noch ein paar Konstanten für unsere Farben:
1 2 3 | publicstaticColorMatcher GREEN=newColorMatcher(„green“,c->c.getG()>128&&c.getG()>c.getR()&&c.getG()>c.getB()); publicstaticColorMatcher PINK=newColorMatcher(„pink“,c->c.getR()>225&&c.getG()<c.getB()&&c.getB()<225);publicstaticColorMatcher BROWN=newColorMatcher(„brown“,c->c.getR()>128&&c.getG()>32&&c.getR()>c.getG()&&c.getG()>=c.getB()); publicstaticColorMatcher WHITE=newColorMatcher(„white“,c->c.getR()>225&&c.getG()>225&&c.getB()>225); |
Natürlich können wir diesen Matcher in einem anderen Matcher wiederverwenden und genau wie zuvor die Lambdas durchreichen.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | publicstaticclassScoopMatcherextendsBaseMatcher{ privatefinalColorMatcher colorMatcher; privatefinalStringflavorIceCream; privatefinalConsistency consistency; privatefinalStringflavorPieces; publicScoopMatcher(ColorMatcher colorMatcher,StringflavorIceCream,Consistency consistency, StringflavorPieces){ super(); this.colorMatcher=colorMatcher; this.flavorIceCream=flavorIceCream; this.consistency=consistency; this.flavorPieces=flavorPieces; } @Override publicbooleanmatches(Objectitem){ if(item instanceofIceCream){ IceCream iceCream=((IceCream)item); returncolorMatcher.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); } returnfalse; } @Override publicvoiddescribeTo(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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Test publicvoidorderTricolore(){ 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),newScoopMatcher(ColorMatcher.GREEN,„pistacchio“,Consistency.CRUNCHY,„pistacchio“)); assertThat(scoops.get(1),newScoopMatcher(ColorMatcher.WHITE,„lemon“,null,null)); assertThat(scoops.get(2),newScoopMatcher(ColorMatcher.PINK,„strawberry“,Consistency.SOFT,„strawberry“)); } |
Natürlich können wir auch einen Matcher für die Waffel schreiben;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | publicstaticclassConeMatcherextendsBaseMatcher{ privatebooleanhandRolled; publicConeMatcher(booleanhandRolled){ super(); this.handRolled=handRolled; } @Override publicbooleanmatches(Objectitem){ returnitem instanceofCone&&((Cone)item).isHandRolled()==handRolled; } @Override publicvoiddescribeTo(Description description){ description.appendText((handRolled?„a handrolled“:„a“)+“ cone“); } } @Test publicvoidorderOneScoopOfChoclate(){ Container order=vendor.order(„Una Tschokkolatta per fawor!“); assertThat(order,is(newConeMatcher(true))); List scoops=order.getScoops(); assertThat(scoops,is(not(nullValue()))); assertThat(scoops.size(),is(1)); assertThat(scoops.get(0),newScoopMatcher(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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | publicstaticConeMatcher aHandrolledCone(){ returnnewConeMatcher(true); } @Test publicvoidorderOneScoopOfChoclate(){ 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),newScoopMatcher(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
1 2 3 4 5 6 7 | 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:
1 2 3 4 5 6 7 | 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | /** * 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 */ publicclassPropertyMatcher<T,P>extendsBaseMatcher<T>{ privateFunction<T,P>propertyGetter; privateMatcher<P>propertyMatcher; privateStringpropertyDescription; publicPropertyMatcher(Function<T,P>propertyGetter,StringpropertyDescription,Matcher<P>propertyMatcher){ this.propertyGetter=propertyGetter; this.propertyDescription=propertyDescription; this.propertyMatcher=propertyMatcher; } @Override publicbooleanmatches(Objectitem){ Pproperty=getNullSafe(item); returnpropertyMatcher.matches(property); } @SuppressWarnings(„unchecked“) publicPgetNullSafe(Objectitem){ returnitem==null?null:propertyGetter.apply((T)item); } @Override publicvoiddescribeTo(Description description){ description.appendText(propertyDescription+‚ ‚); propertyMatcher.describeTo(description); } @Override publicvoiddescribeMismatch(Objectitem,Description description){ Pproperty=getNullSafe(item); super.describeMismatch(property,description); if(propertyMatcher instanceofPropertyMatcher){ description.appendText(„\n because „); ((PropertyMatcher<?,?>)propertyMatcher).getPropertyMatcher().describeMismatch(item,description); } } publicMatcher<P>getPropertyMatcher(){ returnpropertyMatcher; } } |
…und damit Farbe und Geschmack prüfen:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @Test publicvoidorderOneScoopOfChoclate(){ 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( newPropertyMatcher<IceCream,Color>(IceCream::getColor,„colored „,ColorMatcher.BROWN), newPropertyMatcher<IceCream,String>(IceCream::getFlavor,„flavored“,equalTo(„chocolate“)))); } |
Jetzt noch diese Matcher in eigenen Methoden erzeugen:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | privatePropertyMatcher<IceCream,String>flavored(Stringflavor){ returnnewPropertyMatcher<IceCream,String>(IceCream::getFlavor,„flavored“,equalTo(flavor)); } privatePropertyMatcher<IceCream,Color>colored(ColorMatcher color){ returnnewPropertyMatcher<IceCream,Color>(IceCream::getColor,„colored „,color); } privatePropertyMatcher<IceCream,Boolean>withOutPieces(){ returnnewPropertyMatcher<IceCream,Boolean>(i->i.getPieces().isEmpty(),„without pieces“,is(true)); } @Test publicvoidorderOneScoopOfChoclate(){ 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | privatePropertyMatcher<IceCream,Pieces>withPieces(Consistency consistency,Stringflavor){ returnnewPropertyMatcher<IceCream,Pieces>(i->i.getPieces().get(0),„with pieces“, allOf(newPropertyMatcher<Pieces,Consistency>(Pieces::getConsistency,„consistency“,equalTo(consistency)), newPropertyMatcher<Pieces,String>(Pieces::getFlavor,„flavored“,equalTo(flavor)))); } @Test publicvoidorderTricolore(){ 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | /**just renamed,so that it reads better./ @SafeVarargs privatestaticMatcher aScoopOf(Matcher...matchers){ returnallOf(matchers); } @SafeVarargs publicstaticMatcher<List>are(Matcher...scoopMatchers){ @SuppressWarnings(„unchecked“) Matcher<List<?extendsIceCream>>[]matchers=newMatcher[scoopMatchers.length+1]; matchers[0]=newPropertyMatcher<>(List::size,„Number of Scoops“,is(scoopMatchers.length)); for(inti=1;i<matchers.length;i++){ finalintj=i–1; matchers[i]=newPropertyMatcher<>(l->l.size()>j?l.get(j):null,„Scoop[„+i+„]“,scoopMatchers[j]); } returnallOf(matchers); } @Test publicvoidorderTricolore(){ 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | @SafeVarargs publicstaticPropertyMatcher<Container,List>with(Matcher...scoopMatchers){ returnnewPropertyMatcher<Container,List>(Container::getScoops,„with „,are(scoopMatchers)); } @Test publicvoidorderTricolore(){ 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 publicvoidorderOneScoopOfChoclate(){ 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:
1 2 3 4 5 6 7 | java.lang.AssertionError: Expected:is(aplastic cup andwith (Number of Scoops is<3>andScoop[1](colored green andflavored„pistacchio“andwith pieces(consistency<CRUNCHY>andflavored„pistacchio“))andScoop[2](colored white andflavored„lemon“andwithout pieces is<true>)andScoop[3](colored pink andflavored„strawberry“andwith pieces(consistency<SOFT>andflavored„strawberry“)))) but:aplastic cup was<apaper 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.