Pages

lundi 5 mai 2014

Junit et les Rules comment mutualiser du comportement entre vos tests

Depuis plusieurs années les nouvelles fonctionnalités de Junit étaient surtout destinées à rattraper le retard pris sur son concurrent TestNg. Aujourd'hui les deux frameworks convergent au niveau de leur fonctionnalité.



Voici un tableau fait par Zeroturnaround résumant les différences





Je vais parler d'une fonctionnalité méconnue de Junit, les Rules qui n'ont pas d'équivalent dans TestNg.

Mutualiser des fonctionnalités communes
Comment faites vous pour mutualiser du comportement dans vos tests ? …. Je vous aide un peu... Il existe plusieurs solutions.


Tout d'abord au sein même d'une classe de tests, vous pouvez définir des méthodes qui seront lancées
  • après l'intialisation de la classe ou juste avant sa destruction (@BeforeClass ou @AfterClass)
  • après ou avant le lancement d'une méthode via @Before ou @After
Si ce comportement doit être partagé par plusieurs classes vous définissez une classe abstraite. Tous vos tests héritent ensuite de cette classe, même s'ils elles n'utilisent qu'une partie des fonctionnalités proposées. Dans ce cas là, on viole le principe de subsituion de Liskov (une classe fille devrait honorer tout les rôles de sa classe parente).

La dernière solution un peu plus compliquée consiste à définir un Runner Junit. Cette solution est souvent adoptée par les solutions tierces (exemples SpringTest , JunitParams, Archillian..). Mais on ne peut pas définir plus d'un Runner par classe. Si vous devez en utiliser plusieurs vous n'aurez pas d'autres choix que de passer par une classe abstraite.

C'est dans cette optique que les Rules Junit ont été mises en place. Une Rule Junit permet d'ajouter du comportement avant ou après l'exécution de chacune de vos méthodes de tests. Une Rule est ensuite attachée à une classe de tests via l'annotation @Rule.

Rien de mieux que du code pour introduire le sujet, non? Je ne présenterai que des portions de code dans cet article mais l'intégralité des exemples est disponible sous Github.


Comment se passer de l'abstraction ?
Si vous êtes coutumier des tests d'intégration avec une base de données, je suppose que vous connaissez Dbunit. DbUnit demande par exemple de définir sa propre source de données. Mais vous ne le faites pas dans chacune de vos classes de test... On retrouve souvent une classe abstraite permettant de s'interfacer à Dbunit qui ressemble à ça

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceConfig.class})
@TestExecutionListeners({DependencyInjectionTestExecutionListener.class, TransactionalTestExecutionListener.class})
@Transactional
public abstract class AbstractDbunitRepositoryTest {

    private static Properties properties = new Properties();
    protected IDatabaseTester databaseTester;
    protected static String databaseJdbcDriver;
    protected static String databaseUrl;
    protected static String databaseUsername;
    protected static String databasePassword;
    protected IDataSet dataSet;

    @BeforeClass
    public static void initProperties() throws IOException {
        if (databaseJdbcDriver == null) {
            properties.load(AbstractDbunitRepositoryTest.class.getResourceAsStream("/application.properties"));
            databaseJdbcDriver = properties.getProperty("db.driver");
            databaseUrl = properties.getProperty("db.url");
            databaseUsername = properties.getProperty("db.username");
            databasePassword = properties.getProperty("db.password");
        }
    }

    @Before
    public void importDataSet() throws Exception {
        IDataSet dataSet = readDataSet();
        databaseTester = new JdbcDatabaseTester(databaseJdbcDriver, databaseUrl, databaseUsername, databasePassword);
        databaseTester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
        databaseTester.setDataSet(dataSet);
        databaseTester.onSetup();
    }

    protected abstract IDataSet readDataSet();


}


La classe de test peut être par exemple

public class DbUnitTestWithAbstractTest extends AbstractDbunitRepositoryTest {

    @Autowired
    private CountryRepository countryRepository;

    protected IDataSet readDataSet() {
        try {
            return new FlatXmlDataSetBuilder().build(new File("src/test/resources/datasets/country.xml"));
        } catch (MalformedURLException | DataSetException e) {
            throw new RuntimeException(e);
        }
    }

    @Test
    public void shouldFindCountryWhenCodeIsKnown() {
        Country persistantCountry = countryRepository.findCountryByCode("FRA");
        assertThat(persistantCountry.getName()).isEqualTo("France");
    }

}

Définir une Rule
Nous allons donc définir notre première Rule pour simplifier tout ça. Une Rule doit implémenter l'interface TestRule. Pour vous simplifier la vie vous pouvez étendre les classes concrètes fournies par Junit. Ici je vais étendre la classe ExternalRessource qui permet de rajouter du comportement avant ou après une méthode de test.

public class DbUnitRule extends ExternalResource {

    private Properties properties = new Properties();
    protected IDatabaseTester databaseTester;
    protected IDataSet dataSet;
    protected String databaseJdbcDriver;
    protected String databaseUrl;
    protected String databaseUsername;
    protected String databasePassword;

    public DbUnitRule(IDataSet dataSet) {
        this.dataSet = dataSet;
    }


    @Override
    protected void before() throws Throwable {
        initProperties();
        databaseTester = new JdbcDatabaseTester(databaseJdbcDriver, databaseUrl, databaseUsername, databasePassword);
        databaseTester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
        databaseTester.setDataSet(dataSet);
        databaseTester.onSetup();
    }


    private void initProperties() throws IOException {
        if (databaseJdbcDriver == null) {
            properties.load(getClass().getResourceAsStream("/application.properties"));
            databaseJdbcDriver = properties.getProperty("db.driver");
            databaseUrl = properties.getProperty("db.url");
            databaseUsername = properties.getProperty("db.username");
            databasePassword = properties.getProperty("db.password");
        }
    }

}


Comme vous pouvez le constater ce n'est pas plus compliquer que d'écrire la classe abstraite. Au niveau de la classe de tests nous pouvons supprimer l'héritage.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceConfig.class})
@TestExecutionListeners({DependencyInjectionTestExecutionListener.class})
public class DbuniCibleTest {

    @Autowired
    private CountryRepository countryRepository;

    @Rule
    public DbUnitRule rule = new DbUnitRule(readDataSet());

    protected IDataSet readDataSet() {
        try {
            return new FlatXmlDataSetBuilder().build(new File("src/test/resources/datasets/country.xml"));
        } catch (MalformedURLException | DataSetException e) {
            throw new RuntimeException(e);
        }
    }

    @Test
    public void shouldFindCountryWhenCodeIsKnown() {
        Country persistantCountry = countryRepository.findCountryByCode("FRA");
        assertThat(persistantCountry.getName()).isEqualTo("France");
    }

}



Comment se passer de Runner Junit?
Nous avons vu comment nous passer de l'abstraction. Allons plus loin avec le Runner. Le Runner Junit utilisé dans les tests est là pour initier un contexte Spring pour ensuite injecter via un autowire les éléments à tester.

En attendant que Spring intègre dans le projet spring-test une Rule vous pouvez la créer vous même

public class SpringContextRule extends ExternalResource{

    /** Fichiers de conf spring */
    private final Class[] configs;

    /** Classe de test sur laquelle on va lancer l'instrumentation */
    private final Object classToAutowire;

    /** Contexte Spring */
    private final AnnotationConfigApplicationContext context;

    public SpringContextRule(Object classToAutowire, Class ... configs) {
        this.classToAutowire = classToAutowire;
        this.configs = configs;
        this.context = new AnnotationConfigApplicationContext();
    }


    @Override
    protected void before() throws Throwable {
        context.register(configs);
        context.refresh();
        context.getAutowireCapableBeanFactory().autowireBean(classToAutowire);
        context.start();
    }

    @Override
    protected void after() {
        context.close();
    }

    public ApplicationContext getContext() {
        return context;
    }
}

Notre classe de tests devient encore plus simple

public class DbuniCibleTest {

    @Autowired
    private CountryRepository countryRepository;

    @Rule
    public DbUnitRule rule = new DbUnitRule(readDataSet());
 
    @Rule
    public SpringContextRule rule = new SpringContextRule(this, PersistenceConfig.class); 
 
    protected IDataSet readDataSet() {
        try {
            return new FlatXmlDataSetBuilder().build(new File("src/test/resources/datasets/country.xml"));
        } catch (MalformedURLException | DataSetException e) {
            throw new RuntimeException(e);
        }
    }

    @Test
    public void shouldFindCountryWhenCodeIsKnown() {
        Country persistantCountry = countryRepository.findCountryByCode("FRA");
        assertThat(persistantCountry.getName()).isEqualTo("France");
    }

}


Comment chainer des Rules ?

On pourrait maintenant se dire qu'une Rule pourrait dépendre d'une autre. Pour oublier un peu DbUnit je parlerai d'une solution alternative plus élégante à mon sens qui se nomme DbSetup. Si vous ne connaissez pas DbSetup je vous laisse lire la documentation en ligne qui contient tout ce qu'il y a savoir.

DbSetup utilise une datasource. Si vous faites du Spring cette dernière est déjà initialisée dans le contexte et il serait intéressant de la récupérer. Comment pourrait t-on écrire une Rule récupérant le context défini dans une autre? C'est là que rentre en jeu les RuleChain.

Passons à l'exemple la Rule DbSetup est la suivante

public class DbSetupRule extends ExternalResource {
    protected Operation init;
    protected ApplicationContext applicationContext;

    public DbSetupRule(ApplicationContext applicationContext, Operation init) {
        this.applicationContext = applicationContext;
        this.init = init;
    }


    @Override
    protected void before() throws Throwable {
        DbSetup dbSetup = new DbSetup(new DataSourceDestination(applicationContext.getBean(DataSource.class)), init);
        dbSetup.launch();
    }

}

Et la classe de test...

public class CountryRepositoryDbSetupTest {

    private static Operation init = Operations.sequenceOf(
            deleteAllFrom("country"),
            insertInto("country")
                    .columns("id", "code", "name")
                    .values(1, "FRA", "France")
                    .values(2, "USA", "United States")
                    .build()
    );

    public SpringContextRule springContextRule = new SpringContextRule(this, PersistenceConfig.class);

    @Rule
    public TestRule testRule = RuleChain.outerRule(springContextRule).around(new DbSetupRule(springContextRule.getContext(), init));

    @Autowired
    private CountryRepository countryRepository;

    @Test
    public void shouldFindCountryWhenCodeIsKnown() {
        Country persistantCountry = countryRepository.findCountryByCode("FRA");
        assertThat(persistantCountry.getName()).isEqualTo("France");
    }

}

D'autres exemples de Rules ?

Plusieurs Rules sont fournies par Junit : ErrorCollector, ExpectedException, TemporaryFolder, TestWatcher. On les retrouve souvent reprises dans les blogs fournissant des exemples d'implémentation des Rules. Ces Rules n'ont d'intérêt que dans un nombre de uses cases limités et je pense que c'est une des raisons pour laquelle les Rules restent encore trop inutilisées.

Je n'en parlerai pas ici mais quelques exemples sont disponibles dans mon projet exemple sous Github.

Je ne sais pas si je vous ai convaincu mais si vous souhaitez mutualiser du comportement entre des tests gardez à l'esprit que les Rules peuvent répondre à votre besoin.

Aucun commentaire:

Enregistrer un commentaire

Remarque : Seul un membre de ce blog est autorisé à enregistrer un commentaire.