To jest materiał podstawowy, który przygotowałem do wewnętrznej prezentacji w pracy. Jeśli ktoś jest obcykany w matcherach niech nie traci czasu i poczyta sobie lepiej jakieś dzieła w stylu trzynastej księgi Pana Tadeusza.
Jeśli jednak ta tematyka jest Ci obca to zapraszam do lektury
Domena
Domenę biznesową tworzą dwie klasy - piosenka i techno mikser
public class Song {
private String title;
private int numberOfBits;
public Song(String title,int numberOfBits) {
this.title = title;
this.numberOfBits = numberOfBits;
}
public void play(){
System.out.println("plying "+title);
}
public String getTitle() {
return title;
}
public int getNumberOfBits() {
return numberOfBits;
}
}
public class TechnoMixer {
public List makeTechnoMix(Song... originalSongs){
int technoMultiplier = 2;
String technoPostfix = "_techno_mix";
return mixSongs(technoMultiplier, technoPostfix, originalSongs);
}
public List makeThunderdomeMix(Song... originalSongs){
int thunderdommeMultiplier = 4;
String thunderdommePostfix = "_thunderdomme_mix";
return mixSongs(thunderdommeMultiplier, thunderdommePostfix,
originalSongs);
}
private List mixSongs(int bitsMultiplier,
String titlePostfix, Song... originalSongs) {
List mixedSongs=new ArrayList<>();
for (Song originalSong : originalSongs) {
int newNumberOfBits=originalSong.getNumberOfBits()*bitsMultiplier;
String newTitle=originalSong.getTitle()+ titlePostfix;
mixedSongs.add(new Song(newTitle, newNumberOfBits));
}
return mixedSongs;
}
}
Działanie jest proste - TechnoMixer zwyczajnie zwiększa ilość bitów znacząco podnosząc komfort słuchania piosenki :) No i odpowiednio modyfikuje tytuł utworu oznajmując wszem i wobec, że słuchamy lepszej jej formy
Spróbujmy to przetestować...
Test próba pierwsza
@Test
public void shouldMixSongsToTechno() {
//given
Song equador=new Song("Equador", 100);
Song onaTanczyDlaMnie=new Song("Ona Tanczy Dla Mnie", 50);
Song harlemShake=new Song("Harlem Shake", 150);
//when
List mixes = technoMixer.makeTechnoMix(equador,onaTanczyDlaMnie,harlemShake);
//then
Assert.assertEquals(mixes.get(0).getNumberOfBits(), 200);
Assert.assertEquals(mixes.get(1).getNumberOfBits(), 100);
Assert.assertEquals(mixes.get(2).getNumberOfBits(), 300);
Assert.assertEquals(mixes.get(0).getTitle(), "Equador_techno_mix");
Assert.assertEquals(mixes.get(1).getTitle(), "Ona Tanczy Dla Mnie_techno_mix");
Assert.assertEquals(mixes.get(2).getTitle(), "Harlem Shake_techno_mix");
}
Testy przechodzi ale wygląda kiepsko - a pamiętajcie, że to tylko prosty przykład. Przy bardziej skomplikowanych obiektach będzie jeszcze więcej syfu.
Test próba druga - zewnętrzna metoda
@Test
public void shouldMixSongsToTechno2() {
//given
Song equador=new Song("Equador", 100);
Song onaTanczyDlaMnie=new Song("Ona Tanczy Dla Mnie", 50);
Song harlemShake=new Song("Harlem Shake", 150);
//when
List mixes = technoMixer.makeTechnoMix(equador,onaTanczyDlaMnie,harlemShake);
//then
assertThatSongsHaveBits(mixes,200,100,300);
assertThatSongsHaveTitles(mixes,"Equador_techno_mix","Ona Tanczy Dla Mnie_techno_mix","Harlem Shake_techno_mix");
}
private void assertThatSongsHaveBits(List mixes, int... expectedBites) {
for (int currentSongNumber = 0; currentSongNumber < expectedBites.length; currentSongNumber++) {
Assert.assertTrue(mixes.get(currentSongNumber).getNumberOfBits()==expectedBites[currentSongNumber]);
}
}
private void assertThatSongsHaveTitles(List mixes, String... expectedTitles) {
for (int currentSongNumber = 0; currentSongNumber < expectedTitles.length; currentSongNumber++) {
Assert.assertEquals(mixes.get(currentSongNumber).getTitle(),expectedTitles[currentSongNumber]);
}
}
Test wygląda już czytelniej. Metody są bliźniaczo podobne ale nie będziemy ich jeszcze refaktorować. Na razie zastanówmy się nad kilkoma rzeczami :
- Czy wywołanie może być jeszcze czytelniejsze
- Czy można gdzieś przenieść metody pomocnicze aby nie zaśmiecały testu
- Zakłądając, że w bardziej skomplikowanej domenie obiekt Song mógłby wystąpić w większej ilości testów - czy można zrobić jakoś tak aby metod "reużyć"?
Można!
Matcher - wersja pierwsza
@Test
public void shouldMixSongsToTechnoWithMatcher() {
//given
Song equador=new Song("Equador", 100);
Song onaTanczyDlaMnie=new Song("Ona Tanczy Dla Mnie", 50);
Song harlemShake=new Song("Harlem Shake", 150);
//when
List mixes = technoMixer.makeTechnoMix(equador,onaTanczyDlaMnie,harlemShake);
//then
SongMatcher.assertThat(mixes).haveBits(200,100,300).and().titles("Equador_techno_mix","Ona Tanczy Dla Mnie_techno_mix","Harlem Shake_techno_mix");
}
public class SongMatcher {
private List songs;
public SongMatcher(List songs) {
this.songs = songs;
}
public static SongMatcher assertThat(List songs) {
return new SongMatcher(songs);
}
public SongMatcher haveBits(int... expectedBits) {
for (int currentSongNumber = 0; currentSongNumber < expectedBits.length; currentSongNumber++) {
Assert.assertEquals(expectedBits[currentSongNumber],songs.get(currentSongNumber).getNumberOfBits());
}
return this;
}
public SongMatcher titles(String... expectedTitles) {
for (int currentSongNumber = 0; currentSongNumber < expectedTitles.length; currentSongNumber++) {
Assert.assertEquals(expectedTitles[currentSongNumber],songs.get(currentSongNumber).getTitle());
}
return this;
}
public SongMatcher and() {
return this;
}
}
Test wygląda już ok ale w matcherze mamy te jakże podobne do siebie metody. A Gdyby tak obiekt miał 10 własności? Czas na jakiś refaktoring - na tyle na ile znam refleksję to wyjdzie coś takiego....
Matcher - wersja druga
public class SongMatcher2 {
private List songs;
public SongMatcher2(List songs) {
this.songs = songs;
}
public static SongMatcher2 assertThat(List songs) {
return new SongMatcher2(songs);
}
public SongMatcher2 haveBits(Integer... expectedBits) {
assertValueOnProperty("NumberOfBits",expectedBits);
return this;
}
public SongMatcher2 titles(String... expectedTitles) {
assertValueOnProperty("Title",expectedTitles);
return this;
}
private void assertValueOnProperty(String fieldName, Object[] expectedBits) {
Assert.assertNotNull(songs);
Assert.assertFalse(songs.isEmpty());
try {
assertValueOnPropertyUnsafely(fieldName, expectedBits);
} catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
private void assertValueOnPropertyUnsafely(String fieldName,
Object[] expectedBits) throws NoSuchMethodException,
IllegalAccessException, InvocationTargetException {
Method methodToInvoke = songs.get(0).getClass().getDeclaredMethod("get"+fieldName);
for (int currentNumberOfValue = 0; currentNumberOfValue < expectedBits.length; currentNumberOfValue++) {
Song currentSong = songs.get(currentNumberOfValue);
Object invokeResult = methodToInvoke.invoke(currentSong,new Object[0]);
Assert.assertEquals(expectedBits[currentNumberOfValue], invokeResult);
}
}
public SongMatcher2 and() {
return this;
}
}
No i uważne oko zauważy, że wykształcił nam się taki matcher w matcherze. Zakładając, że obiektów w domenie będzie więcej to być może znowu jakoś tę logikę z refleksją da się gdzieś tam ładnie udostępnić. Możemy alb o delegować albo dziedziczyć. Niby książki mówią, żeby delegować ale tutaj z powodów nad którymi teraz nie chce mi się rozwodzić będziemy dziedziczyć.
Matcher - wersja trzecia
public class BaseMatcher {
protected List objectsUnderTest;
public BaseMatcher(List objectsUnderTest) {
this.objectsUnderTest = objectsUnderTest;
}
protected void assertValueOnProperty(String fieldName, Object[] expectedValues) {
Assert.assertNotNull(objectsUnderTest);
Assert.assertFalse(objectsUnderTest.isEmpty());
try {
assertValueOnPropertyUnsafely(fieldName, expectedValues);
} catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
private void assertValueOnPropertyUnsafely(String fieldName,
Object[] expectedBits) throws NoSuchMethodException,
IllegalAccessException, InvocationTargetException {
Method methodToInvoke = objectsUnderTest.get(0).getClass().getDeclaredMethod("get"+fieldName);
for (int currentNumberOfValue = 0; currentNumberOfValue < expectedBits.length; currentNumberOfValue++) {
Object currentObject = objectsUnderTest.get(currentNumberOfValue);
Object invokeResult = methodToInvoke.invoke(currentObject,new Object[0]);
Assert.assertEquals(expectedBits[currentNumberOfValue], invokeResult);
}
}
public BaseMatcher and() {
return this;
}
}
public class SongMatcher3 extends BaseMatcher{
public SongMatcher3(List songs) {
super(songs);
}
public static SongMatcher3 assertThat(List songs) {
return new SongMatcher3(songs);
}
public SongMatcher3 haveBits(Integer... expectedBits) {
assertValueOnProperty("NumberOfBits",expectedBits);
return this;
}
public SongMatcher3 titles(String... expectedTitles) {
assertValueOnProperty("Title",expectedTitles);
return this;
}
}
Na razie możemy testować tylko listy ale to na potrzeby przykładu i jeszcze do tego wrócimy. Wygląda całkiem obiecująco ale pojawia się pewien problem otóż kompilator zgłasza błąd w teście :
@Test
public void shouldMixSongsToTechnoWithMatcher3() {
//given
Song equador=new Song("Equador", 100);
Song onaTanczyDlaMnie=new Song("Ona Tanczy Dla Mnie", 50);
Song harlemShake=new Song("Harlem Shake", 150);
//when
List mixes = technoMixer.makeTechnoMix(equador,onaTanczyDlaMnie,harlemShake);
//then
SongMatcher3.assertThat(mixes).haveBits(200,100,300).and();// O TUTAJ JEST BŁĄD .titles("Equador_techno_mix","Ona Tanczy Dla Mnie_techno_mix","Harlem Shake_techno_mix");
}
Dzieje się tak dlatego, że nasz matcher bazowy nic nie wie o metodach matchera domenowego. Ostatni szlif i ostatnia sztuczka własnie przed nami. Musimy jakoś tę wiedzę do nadklasy przekazać.
Matcher - wersja czwarta
public class BaseMatcher2 {
private K self;
protected List objectsUnderTest;
public BaseMatcher2(List objectsUnderTest,Class selfClass) {
this.objectsUnderTest = objectsUnderTest;
this.self=selfClass.cast(this);
}
protected void assertValueOnProperty(String fieldName, Object[] expectedValues) {
Assert.assertNotNull(objectsUnderTest);
Assert.assertFalse(objectsUnderTest.isEmpty());
try {
assertValueOnPropertyUnsafely(fieldName, expectedValues);
} catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
private void assertValueOnPropertyUnsafely(String fieldName,
Object[] expectedBits) throws NoSuchMethodException,
IllegalAccessException, InvocationTargetException {
Method methodToInvoke = objectsUnderTest.get(0).getClass().getDeclaredMethod("get"+fieldName);
for (int currentNumberOfValue = 0; currentNumberOfValue < expectedBits.length; currentNumberOfValue++) {
Object currentObject = objectsUnderTest.get(currentNumberOfValue);
Object invokeResult = methodToInvoke.invoke(currentObject,new Object[0]);
Assert.assertEquals(expectedBits[currentNumberOfValue], invokeResult);
}
}
public K and() {
return self;
}
}
public class SongMatcher4 extends BaseMatcher2{
public static SongMatcher4 assertThat(List songs) {
return new SongMatcher4(songs);
}
public SongMatcher4(List objectsUnderTest) {
super(objectsUnderTest, SongMatcher4.class);
}
public SongMatcher4 haveBits(Integer... expectedBits) {
assertValueOnProperty("NumberOfBits",expectedBits);
return this;
}
public SongMatcher4 titles(String... expectedTitles) {
assertValueOnProperty("Title",expectedTitles);
return this;
}
}
Teraz powinno być ok
Co dalej?
Jak już wcześniej zwróciliśmy uwagę powyższe matchery nadają się jedynie do testowania list. Można by klasę bazową zamienić w fabrykę wyspecjalizowanych matecherów co ma sens bo testowania czy ilość elementów jest większa od zera nie ma sensu dla pojedynczych obiektów. I coś takiego jest już gotowe, nazywa się FEST i gorąco polecam
Co Jeszcze?
Przy okazji pisania artykułu użyłem JUnit 4.11, który ma nowy ciekawy patent : @FixMethodOrder(MethodSorters.NAME_ASCENDING). Od czasu przesiadki na Javę 7 JUnit zaczął wywoływać metody w losowej kolejności co czasami psuje testy integracyjne bo tam np. testując flow transformacji plików dla zaoszczędzenia czasu kolejne testy korzystają ze stanu pozostawionego przez poprzednie fazy. A ta małą adnotacja działa jak do rany przyłóż.