Finalist

Finalist Developers Blog

Unit testen met mock objecten in een Dependency Injection container

20 January 2010 12:32 · Rob Schellhorn · Algemeen, Java

Het grote verkoop argument om te kiezen voor een Depency Injection (DI) container is de losse koppeling tussen componenten. Het systeem bestaat uit kleine componenten die makkelijk ‘aan elkaar geknoopt’ kunnen worden. Dit leidt onder andere tot betere, (unit) testbare code. In een unit test wil je immers een specifieke unit testen, waarbij onderliggende componenten zoveel mogelijk constant gehouden worden. Het constante gedrag van zo’n onderliggend object wordt gerealiseerd door een mock implementatie te injecteren: een implementatie specifiek voor de test. In deze blog post wil ik aan de hand van een voorbeeld use case laten zien hoe je deze techniek effectief kan inzetten.

De use case: beoordelen van items

Het voorbeeld dat ik wil gebruiken is het beoordelen van items, of eigenlijk het ophalen van de gemiddelde waardering. De gebruiker kan items, die uniek geïdentificeerd worden met een string, beoordelen met een waarde tussen de 0 en de 10. In de repository worden waarderingen genormaliseerd opgeslagen als floating point getal tussen 0 en de 1. Op die manier is men voorbereid als in de toekomst de schaal aangepast wordt. De service biedt de mogelijkheid de gemiddelde waardering weer op te vragen, waarbij dus een vertaalslag gemaakt moet worden.

Hoewel de voorbeeld code in deze post is uitgewerkt in Spring (DI Container), JUnit (Unit Test runner) en JMock (Dynamische mock objecten), is de techniek algemeen toepasbaar.

De use case draait zoals gezegd om 2 units, de repository en de service, waarbij de laatste de unit under test is. De repository zal dus gemocked moeten worden. Zie onderstaande implementatie:

public interface RatingRepository {
  float getRating(String id);
}
 
@Service
final class RatingServiceImpl implements RatingService {
  @Inject private RatingRepository rep;
 
  public int getRating(String id) {
    float norm = rep.getRating(id);     // dependency
    if (norm < 0 || norm > 1)
      throw new RatingException();      // test case
    int rating = Math.round(norm * 10); // test case
    return rating;
  }
}

Fragment 1: Service implementatie

In de geïmplementeerde service methode zijn twee test cases te identificeren:

  • De repository houdt zich niet aan zijn contract en geeft een getal terug dat niet tussen 0 of 1 ligt.
  • De service voert de transformatie niet goed uit.

Dit laat zich vertalen naar 7 condities die getest moeten worden:

Conditie Repository antwoord Verwacht resultaat
1. Onderste randwaarde 0 0
2. Bovenste randwaarde 1 0
3. Doorsnee waarde 0.5 5
4. Afronden 0.46 5
5. Afronden 0.54 5
6. Repository faalt -1 Exceptie
7. Repository faalt 2 Exceptie

Laten we eerst kijken wat er nodig is om de repositoy met de hand te mocken.

Met de hand geschreven mock objecten

Een mock object van de RatingRepository staat het toe dat precies deze 7 condities getest kunnen worden. De mock implementatie en bijbehorende unit test zien er als volgt uit:

class MockRatingRepository implement RatingRepository {
  public float getRating(String id) {
    if ("1".equals(id)) return 0f;
    else if ("2".equals(id)) return 1f;
    else if ("3".equals(id)) return 5f;
    else if ("4".equals(id)) return 0.46f;
    else if ("5".equals(id)) return 0.54f;
    else if ("6".equals(id)) return -1f;
    else /* if ("7".equals(id)) */ return 2f;
  }
}
 
@ContextConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
public class RatingServiceTest {
  @Inject private RatingService service;
 
  @Test
  public void convert_normalized_rating() {
    assertEquals(0,  service.getRating("1"));
    assertEquals(10, service.getRating("2"));
    assertEquals(5,  service.getRating("3"));
    assertEquals(5,  service.getRating("4"));
    assertEquals(5,  service.getRating("5"));
  }
 
  @Test(expected = RatingException.class)
  public void less_than_0() {
    service.getRating("6");
  }
 
  @Test(expected = RatingException.class)
  public void greater_than_1() {
    service.getRating("7");
  }
}

Fragment 2: Handmatig mock implementatie en bijbehorende unit test

Hoewel de unit test correct geïmplementeerd is, heb heb ik twee grote bezwaren tegen deze aanpak:

  • De testlogica staat verspreid over twee klassen. Het mock object heeft alleen bestaansrecht in de context van de unit test.
  • In een real-life situatie heeft de RatingRepository meer methoden. Deze methoden zul je ook moeten mocken, terwijl je ze niet gebruikt. Het mock object wordt code die onderhouden moet worden.

Door gebruik te maken van een mock framework kan je deze nadelen omzeilen.

JMock: Dynamische mock objecten

Mock frameworks zoals JMock kunnen dynamisch implementaties genereren aan de hand van een gegeven interface. Het verwachte gedrag van zo’n mock object kan geprogrammeerd worden binnen de context van de test.

@ContextConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
public class RatingServiceTest {
  @Inject private Mockery mockery;
  @Inject private RatingRepository rep;
  @Inject private RatingService service;
 
  @Test
  public void convert_normalized_rating() {
    mockery.checking(new Expectations() {{
      oneOf(rep).getRating("1"); will(returnValue(0f));
      oneOf(rep).getRating("2"); will(returnValue(1f));
      oneOf(rep).getRating("3"); will(returnValue(0.5f));
      oneOf(rep).getRating("4"); will(returnValue(0.46f));
      oneOf(rep).getRating("5"); will(returnValue(0.54f));
    }});
    assertEquals(0,  service.getRating("1"));
    assertEquals(10, service.getRating("2"));
    assertEquals(5,  service.getRating("3"));
    assertEquals(5,  service.getRating("4"));
    assertEquals(5,  service.getRating("5"));
  }
 
  @Test(expected = RatingException.class)
  public void less_than_0() {
    mockery.checking(new Expectations() {{
      oneOf(rep).getRating("6"); will(returnValue(-1f));
    }});
    service.getRating("6");
  }
 
  @Test(expected = RatingException.class)
  public void greater_than_1() {
    mockery.checking(new Expectations() {{
      oneOf(rep).getRating("7"); will(returnValue(2f));
    }});
    service.getRating("7");
  }
}

Fragment 3: Dynamische mock implementatie en bijbehorende unit test

Zoals je ziet hoef je alleen het verwachtte gedrag van de mock repository te definiëren. Dit gebeurt veel dichter bij de daadwerkelijk test, waardoor het overzicht beter bewaard blijft.

Conclusie

Unit testen is in principe alleen mogelijk als componenten in het systeem los te benaderen zijn. Een DI container dwingt deze programmeerwijze op een natuurlijke manier af. Hierdoor kunnen componenten gemakkelijk worden vervangen door mock implementaties. Door een dynamisch mock framework te gebruiken en niet zelf het mock object uit te programmeren, houd je de logica van een test bij elkaar.

Share and Enjoy:
  • E-mail this story to a friend!
  • Print this article!
  • Digg
  • del.icio.us
  • Facebook
  • Google Bookmarks
  • Blogosphere News
  • Fleck
  • NuJIJ
  • Slashdot
  • StumbleUpon
  • LinkedIn
  • Twitter

Reageer

RSS feed for comments on this post · TrackBack URI