niedziela, 26 maja 2013

Od Zera Do Matchera

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łóż.

5 komentarzy:

  1. W TestNG zależność testów jest zdecydowanie fajniej rozwiązana.

    Do FEST jest nawet plugin mavenowy, który umie wygenerować asercje dla klas w projekcie - https://github.com/joel-costigliola/maven-fest-assertion-generator-plugin. Jest głównie mojego autorstwa niemniej jednak powinien działać :P

    OdpowiedzUsuń
  2. Wiem czytałem :)

    http://michalostruszka.pl/blog/2012/10/17/maven-plugin-for-generating-fest-assertions/

    OdpowiedzUsuń
  3. Jest też nawet FEST 2.0: https://github.com/alexruiz/fest-assert-2.x trochę poprawiony od 1.X
    A co do pluginów do FEST'a, to jeszcze jest integrujący się z Eclipse'm: http://joel-costigliola.github.io/fest-eclipse-plugin/

    OdpowiedzUsuń
  4. Spoko ten plugin - oblukam w wolnej chwili. Dzięki za namiar!

    OdpowiedzUsuń
  5. Kiedyś jeszcze próbowałem się bawić w DSL-assercje na językach dynamicznych ale to jeszcze nie był ten czas. Może niedługo...

    OdpowiedzUsuń