Tests automatisés avec JUnit et Spring

Dans une architecture raisonnable, la couche de services qui contient les traitements métier et la couche d’accès aux données, ou DAO[1], qui contient toutes les requêtes JDBC[2] ou la manipulation des sessions hibernate, sont strictement disjoints.

Dans cette architecture, les transactions sont gérées par la couche de services. Avec Spring Framework, ces transactions peuvent être gérées de façon automatique, à l’aide d’annotations ou par programmation, avec ou sans Spring Boot.

Schéma de principe des tests avec Spring

Test des beans de services

Dans cette architecture, tester les beans de service est la tâche la plus importante. Pour ça, la classe de test doit pouvoir accéder au bean dans son contexte Spring. Mais ça ne pose pas vraiment de problème puisqu’aucun prérequis n’est exigé, du point de vue transactionnel.

Initialisation par programmation

La classe de test peut initialiser elle-même le contexte Spring, et récupérer le bean à tester, dans une méthode annotée @BeforeAll.

@TestInstance(Lifecycle.PER_CLASS)
class ProductServiceTest {

  ProductService service;

  @BeforeAll
  void init() {
    ClassPathXmlApplicationContext context
            = new ClassPathXmlApplicationContext("application-config.xml");
    service = context.getBean(ProductService.class);
  }

   ...
 }

La suite du test se déroule de façon traditionnelle : une méthode de test, annotée par @Test, par méthode à tester, ou à peu près…​

public class CourseServiceTest {
  ...

  @Test
  void getAll_should_work() {
    // GIVeN

    // WHeN
    List<Product> products = service.getAll();

    // THeN
    assertThat(products).isEmpty();
  }

  ...
 }

Initialisation par annotation

Plutôt que de gérer les initialisations manuellement, il est possible de demander à Spring d’injecter les beans nécessaires.

La première étape est l’annotation de la classe pour que le test transite par Spring, avec le bon fichier de contexte.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations={"/application-config.xml"})
public class ProductServiceTest {
  ...
}

La seconde opération est d’injecter le bean à tester, avec l’annotation @Autowired ou avec l’annotation @Resource.

  @Autowired
  ProductService service;

La fin est identique à la technique traditionnelle : développer les méthodes de test.

Initialisation par annotation avec Spring Boot

Si vous développez avec Spring Boot, l’extension JUnit peut être remplacée par l’annotation @SpringBootTest. Le reste du test reste identique.

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ProductServiceBootTest {

  @Autowired
  ProductService service;

  ...
}

Test des beans de DAO

Le test des beans de DAO pose un problème supplémentaire : celui des transactions. En effet, lorsqu’une méthode de DAO est appelée, un transaction doit être démarrée, puis doit être validée ou annulée après l’appel de la méthode.

Cette gestion de transaction doit donc être prise en charge par la classe de test.

Transactions par programmation

En accédant au bean TransactionManager, il est possible de démarrer et de conclure des transactions depuis la classe de test. Ces transactions engloberont naturellement les requêtes soumises à la base par le composant DAO.

@TestInstance(Lifecycle.PER_CLASS)
public class ProductDaoTest {

  PlatformTransactionManager transactionManager;
  ProductDao productDao;

  @BeforeAll
  void initAll() throws Exception {
    ...
    transactionManager = context.getBean(PlatformTransactionManager.class);
  }

  @Test
  void findByTitle_should_work() {
    // GIVeN

    // WHeN
    TransactionStatus status = transactionManager.getTransaction(null);
    List<Product> products = productDao.findByTitleLike("TEST");
    transactionManager.commit(status);

    // THeN
    ...
  }

  ...
}

J’aime bien améliorer la lisibilité de cette forme en créant une méthode utilitaire runInTransaction(…​), avec une expression lambda en paramètre.

@TestInstance(Lifecycle.PER_CLASS)
public class ProductDaoTest {

  ...

  <T> T runInTransaction(Supplier<T> supplier) {
    TransactionStatus status = transactionManager.getTransaction(null);
    T result = supplier.get();
    transactionManager.commit(status);
    return result;
  }

  @Test
  void findByTitle_should_work() {
    // GIVeN

    // WHeN
    List<Product> products = runInTransaction(
      () -> productDao.findByTitleLike("TEST")
    );

    // THeN
    ...
  }

  ...
}

Cette technique de manipulation de transaction fonctionnait déjà avec Spring 1.x ! Pour la méthode runInTransaction(…​), il a fallu attendre le JDK 8.

Transactions par annotation

Pour que la classe de test puisse démarrer et valider les transactions, il faut ajouter l’annotation @Transactional.

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations={"/application-config.xml"})
@Transactional
public class ProductDaoTest {
  ...
}

Les tests peuvent se faire comme pour une classe de service : injection du bean (@Autowired ou @Resource) puis méthodes de test.

Cette technique de manipulation de transaction a été une des innovations de Spring 2.5, qui a été utilisée pour la rédaction initiale de cette page en 2008. Elle reste compatible avec Spring Boot.

@SpringBootTest
@Transactional
class ProductDaoBootTest {
  ...
}

Lorsqu’une méthode de test est transactionnelle, les méthodes @BeforeEach et @AfterEach s’exécutent dans la même transaction. Pour avoir l’équivalent en dehors de la transaction, il faut utiliser les annotations spécifiques à Spring @BeforeTransaction et @AfterTransaction.

Les méthodes @BeforeAll et @AfterAll s’exécutent en dehors de la transaction.

Références

  • Exemples de code, avec Spring 5.3, Spring Boot 2.6 et JUnit 5.8
    (Version initiale rédigée et testée avec Spring 2.5, JUnit 4.4 et Hibernate 3.2)


1. Data Access Object
2. Java DataBase Connectivity