Bonnes pratiques de test Java





Afin d'avoir une couverture de code suffisante et de créer de nouvelles fonctionnalités et de refactoriser les anciennes sans craindre de casser quelque chose, les tests doivent être maintenables et facilement lisibles. Dans cet article, je parlerai de nombreuses techniques d'écriture de tests unitaires et d'intégration en Java, que j'ai collectées au fil des ans. Je vais m'appuyer sur des technologies modernes: JUnit5, AssertJ, Testcontainers, et je n'ignorerai pas non plus Kotlin. Certains des conseils vous sembleront évidents, tandis que d'autres peuvent aller à l'encontre de ce que vous avez lu dans les livres sur le développement et les tests de logiciels.



En un mot



  • Écrire des tests de manière concise et spécifique, en utilisant des fonctions d'assistance, le paramétrage, diverses primitives de la bibliothèque AssertJ, ne pas abuser des variables, vérifier uniquement ce qui est lié à la fonctionnalité testée et ne pas coller tous les cas non standard dans un seul test
  • , ,
  • , -,
  • KISS DRY
  • , , , in-memory-
  • JUnit5 AssertJ —
  • : , , Clock - .




Given, When, Then (, , )



Le test doit contenir trois blocs séparés par des lignes vides. Chaque bloc doit être aussi court que possible. Utilisez des méthodes locales pour garder les choses compactes.



Donné / Donné (entrée): préparation du test, par exemple, création de données et configuration de simulation.

When (action): appeler la méthode testée

Then / To (sortie): vérifier l'exactitude de la valeur reçue



// 
@Test
public void findProduct() {
    insertIntoDatabase(new Product(100, "Smartphone"));

    Product product = dao.findProduct(100);

    assertThat(product.getName()).isEqualTo("Smartphone");
}


Utilisez les préfixes «réel *» et «attendu *»



// 
ProductDTO product1 = requestProduct(1);

ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);



Si vous comptez utiliser des variables dans un test de correspondance, ajoutez les préfixes «réel» et «attendu» à ces variables. Cela améliorera la lisibilité de votre code et clarifiera le but des variables. Cela les rend également plus difficiles à confondre lors de la comparaison.



// 
ProductDTO actualProduct = requestProduct(1);

ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); //   


Utilisez des valeurs prédéfinies au lieu de valeurs aléatoires



Évitez de fournir des valeurs aléatoires à l'entrée des tests. Cela peut conduire à des tests clignotants, ce qui est sacrément difficile à déboguer. En outre, si vous voyez une valeur aléatoire dans un message d'erreur, vous ne pouvez pas la retracer à l'endroit où l'erreur s'est produite.



// 
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad



Utilisez différentes valeurs prédéfinies pour tout. De cette façon, vous obtiendrez des résultats de test parfaitement reproductibles et vous trouverez rapidement le bon endroit dans le code par le message d'erreur.



// 
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");



Vous pouvez l'écrire encore plus court en utilisant les fonctions d'assistance (voir ci-dessous).



Rédiger des tests concis et spécifiques



Utilisez les fonctions d'assistance lorsque cela est possible



Isolez le code répétitif dans les fonctions locales et donnez-leur des noms significatifs. Cela gardera vos tests compacts et faciles à lire en un coup d'œil.



// 
@Test
public void categoryQueryParameter() throws Exception {
    List<ProductEntity> products = List.of(
            new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
            new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
            new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
    );
    for (ProductEntity product : products) {
        template.execute(createSqlInsertStatement(product));
    }

    String responseJson = client.perform(get("/products?category=Office"))
            .andExpect(status().is(200))
            .andReturn().getResponse().getContentAsString();

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


// 
@Test
public void categoryQueryParameter2() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("1", "Office"),
            createProductWithCategory("2", "Office"),
            createProductWithCategory("3", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


  • utiliser des fonctions d'assistance pour créer des données (objets) ( createProductWithCategory()) et des contrôles complexes. Ne transmettez que ces paramètres aux fonctions d'assistance qui sont pertinentes dans ce test; pour le reste, utilisez les valeurs par défaut adéquates. Dans Kotlin, il existe des valeurs de paramètres par défaut pour cela, et en Java, vous pouvez utiliser des chaînes d'appels de méthode et une surcharge pour simuler les paramètres par défaut.
  • la liste de paramètres de longueur variable rendra votre code encore plus élégant ( ìnsertIntoDatabase())
  • Les fonctions d'assistance peuvent également être utilisées pour créer des valeurs simples. Kotlin le fait encore mieux grâce aux fonctions d'extension.


//  (Java)
Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)
UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")


//  (Kotlin)
val ts = 1.toInstant()
val id = 1.toUUID()


Les fonctions d'assistance dans Kotlin peuvent être implémentées comme ceci:



fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())

fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")


N'abusez pas des variables



Le réflexe conditionné du programmeur est de déplacer les valeurs fréquemment utilisées en variables.



// 
@Test
public void variables() throws Exception {
    String relevantCategory = "Office";
    String id1 = "4243";
    String id2 = "1123";
    String id3 = "9213";
    String irrelevantCategory = "Hardware";
    insertIntoDatabase(
            createProductWithCategory(id1, relevantCategory),
            createProductWithCategory(id2, relevantCategory),
            createProductWithCategory(id3, irrelevantCategory)
    );

    String responseJson = requestProductsByCategory(relevantCategory);

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly(id1, id2);
}


Hélas, c'est une surcharge de code. Pire encore, voir la valeur dans le message d'erreur sera impossible de retracer l'endroit où l'erreur s'est produite.

"KISS est plus important que DRY"


// 
@Test
public void variables() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("4243", "Office"),
            createProductWithCategory("1123", "Office"),
            createProductWithCategory("9213", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("4243", "1123");
}


Si vous essayez d'écrire des tests aussi compacts que possible (ce que je recommande vivement de toute façon), alors les valeurs réutilisées sont clairement visibles. Le code lui-même devient plus compact et plus lisible. Enfin, le message d'erreur vous mènera à la ligne exacte où l'erreur s'est produite.



N'étendez pas les tests existants pour "ajouter une petite chose de plus"



// 
public class ProductControllerTest {
    @Test
    public void happyPath() {
        //   ...
    }
}


Il est toujours tentant d'ajouter un cas particulier à un test existant qui valide les fonctionnalités de base. Mais en conséquence, les tests deviennent plus gros et plus difficiles à comprendre. Des cas particuliers éparpillés sur une grande feuille de code sont faciles à ignorer. Si le test échoue, vous ne comprendrez peut-être pas immédiatement ce qui l'a causé exactement.



// 
public class ProductControllerTest {
    @Test
    public void multipleProductsAreReturned() {}
    @Test
    public void allProductValuesAreReturned() {}
    @Test
    public void filterByCategory() {}
    @Test
    public void filterByDateCreated() {}
}


À la place, écrivez un nouveau test avec un nom descriptif qui indique immédiatement le comportement attendu du code testé. Oui, vous devrez taper plus de lettres sur le clavier (par contre, laissez-moi vous le rappeler, les fonctions d'assistance aident bien), mais vous obtiendrez un test simple et compréhensible avec un résultat prévisible. C'est un excellent moyen de documenter les nouvelles fonctionnalités, soit dit en passant.



Vérifiez uniquement ce que vous voulez tester



Pensez à la fonctionnalité que vous testez. Évitez de faire des vérifications inutiles simplement parce que vous le pouvez. De plus, soyez conscient de ce qui a déjà été testé dans des tests écrits précédemment et ne le testez pas à nouveau. Les tests doivent être compacts et leur comportement attendu doit être évident et dépourvu de détails inutiles.



Disons que nous voulons tester un handle HTTP qui renvoie une liste de produits. Notre suite de tests doit contenir les tests suivants:



1. Un grand test de mappage qui vérifie que toutes les valeurs de la base de données sont correctement renvoyées dans la réponse JSON et sont correctement affectées au format correct. Nous pouvons facilement l'écrire en utilisant les fonctions isEqualTo()(pour un seul élément) ou containsOnly()(pour plusieurs éléments) du package AssertJ, si vous implémentez la méthode correctementequals()...



String responseJson = requestProducts();

ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
        .containsOnly(expectedDTO1, expectedDTO2);


2. Plusieurs tests qui vérifient le comportement correct du paramètre? Category. Ici, nous voulons seulement vérifier si les filtres fonctionnent correctement, pas les valeurs de propriété, car nous l'avons fait auparavant. Par conséquent, il nous suffit de vérifier les correspondances de l'ID produit reçu:



String responseJson = requestProductsByCategory("Office");

assertThat(toDTOs(responseJson))
        .extracting(ProductDTO::getId)
        .containsOnly("1", "2");


3. Quelques tests supplémentaires qui vérifient les cas spéciaux ou la logique métier spéciale, par exemple, que certaines valeurs de la réponse sont calculées correctement. Dans ce cas, nous ne sommes intéressés que par quelques champs de l'ensemble de la réponse JSON. Ainsi, nous documentons cette logique particulière avec notre test. Il est clair que nous n'avons besoin de rien d'autre que ces champs ici.



assertThat(actualProduct.getPrice()).isEqualTo(100);


Tests autonomes



Ne cachez pas les paramètres pertinents (dans les fonctions d'assistance)



// 
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Il est pratique d'utiliser des fonctions d'assistance pour générer des données et vérifier les conditions, mais elles doivent être appelées avec des paramètres. Acceptez les paramètres pour tout ce qui est significatif dans le test et doit être contrôlé à partir du code de test. Ne forcez pas le lecteur à sauter dans la fonction d'assistance pour comprendre la signification du test. Une règle simple: la signification du test doit être claire lorsque l'on regarde le test lui-même.



// 
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Conservez les données de test dans les tests eux-mêmes



Tout devrait être à l'intérieur. Il est tentant de transférer certaines des données dans une méthode @Beforeet de les réutiliser à partir de là. Mais cela obligera le lecteur à parcourir le fichier pour comprendre ce qui se passe exactement ici. Encore une fois, les fonctions d'assistance vous aideront à éviter les répétitions et à rendre vos tests plus faciles à comprendre.



Utiliser la composition au lieu de l'héritage



Ne créez pas de hiérarchies de classes de test complexes.



// 
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}


De telles hiérarchies compliquent la compréhension et vous vous retrouverez probablement rapidement à écrire le prochain successeur du test de base, à l'intérieur duquel beaucoup de déchets sont cousus dont le test actuel n'a pas du tout besoin. Cela distrait le lecteur et conduit à des erreurs subtiles. L'héritage n'est pas flexible: pensez-vous que vous pouvez utiliser toutes les méthodes d'une classe AllInclusiveBaseTest, mais aucune de ses parents ? AdvancedBaseTest?De plus, le lecteur devra constamment sauter entre les différentes classes de base pour comprendre la situation dans son ensemble.

«Il vaut mieux dupliquer du code que de choisir la mauvaise abstraction» (Sandi Metz)



Je recommande d'utiliser la composition à la place. Écrivez de petits extraits et des classes pour chaque tâche liée à un appareil (démarrer une base de données de test, créer un schéma, insérer des données, démarrer un serveur factice). Réutilisez ces pièces dans une méthode @BeforeAllou en affectant les objets créés aux champs de la classe de test. De cette façon, vous pourrez construire chaque nouvelle classe de test à partir de ces blancs, comme à partir de pièces Lego. En conséquence, chaque test aura son propre ensemble de luminaires compréhensibles et garantira que rien en dehors ne se produira. Le test devient autonome, car il contient tout ce dont vous avez besoin.



// 
public class MyTest {
    //   
    private JdbcTemplate template;
    private MockWebServer taxService;

    @BeforeAll
    public void setupDatabaseSchemaAndMockWebServer() throws IOException {
        this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
        this.taxService = new MockWebServer();
        taxService.start();
    }
}


//   
public class DatabaseFixture {
    public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {
        PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");
        db.start();
        DataSource dataSource = DataSourceBuilder.create()
                .driverClassName("org.postgresql.Driver")
                .username(db.getUsername())
                .password(db.getPassword())
                .url(db.getJdbcUrl())
                .build();
        JdbcTemplate template = new JdbcTemplate(dataSource);
        SchemaCreator.createSchema(template);
        return template;
    }
}


Encore une fois:

"KISS est plus important que DRY"


Les tests simples sont bons. Comparez le résultat avec des constantes



Ne réutilisez pas le code de production



Les tests doivent valider le code de production et non le réutiliser. Si vous réutilisez le code de combat dans un test, vous risquez de manquer un bogue dans ce code car vous ne le testez plus.



// 
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));

ProductDTO actualDTO = requestProduct(1);


//   
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);


Pensez plutôt en termes d'entrée et de sortie lors de l'écriture des tests. Le test fournit des données à l'entrée et compare la sortie avec des constantes prédéfinies. La plupart du temps, la réutilisation du code n'est pas requise.



// Do
assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));


Ne copiez pas la logique métier dans les tests



Le mappage d'objets est un excellent exemple de cas où les tests extraient la logique du code de combat en eux-mêmes. Supposons que notre test contienne une méthode mapEntityToDto()dont le résultat est utilisé pour vérifier que le DTO résultant contient les mêmes valeurs que les éléments qui ont été ajoutés à la base au début du test. Dans ce cas, vous copiez très probablement le code de combat dans le test, qui peut contenir des erreurs.



// 
ProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);

ProductDTO actualDTO = requestProduct(1);

 // mapEntityToDto()    ,   -
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);



La solution correcte consiste à le actualDTOcomparer à un objet de référence créé manuellement avec les valeurs spécifiées. C'est extrêmement simple, direct et protège contre les erreurs potentielles.



// 
ProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);


Si vous ne souhaitez pas créer et vérifier une correspondance pour l'ensemble de l'objet de référence, vous pouvez vérifier l'objet enfant ou généralement uniquement les propriétés de l'objet qui sont pertinentes pour le test.



N'écrivez pas trop de logique



Permettez-moi de vous rappeler que les tests concernent principalement les entrées et les sorties. Soumettez les données et vérifiez ce qui vous est retourné. Il n'est pas nécessaire d'écrire une logique complexe à l'intérieur des tests. Si vous introduisez des boucles et des conditions dans un test, vous le rendez moins compréhensible et plus sujet aux erreurs. Si votre logique de validation est complexe, utilisez les nombreuses fonctions AssertJ pour faire le travail à votre place.



Exécutez des tests dans un environnement de combat



Testez l'ensemble de composants le plus complet possible



Il est généralement recommandé de tester chaque classe de manière isolée en utilisant des simulations. Cette approche présente cependant des inconvénients: de cette manière, l'interaction des classes entre elles n'est pas testée, et toute refactorisation d'entités générales cassera tous les tests à la fois, car chaque classe interne a ses propres tests. De plus, si vous écrivez des tests pour chaque classe, il y en aura tout simplement trop.





Test unitaire isolé de chaque classe



Au lieu de cela, je recommande de se concentrer sur les tests d'intégration. Par "tests d'intégration", j'entends collecter toutes les classes ensemble (comme en production) et tester l'ensemble du bundle, y compris les composants d'infrastructure (serveur HTTP, base de données, logique métier). Dans ce cas, vous testez le comportement au lieu de l'implémentation. Ces tests sont plus précis, plus proches du monde réel et résistants à la refactorisation des composants internes. Idéalement, une seule classe de tests suffira.





Test d'intégration (= rassembler toutes les classes et tester le bundle)



N'utilisez pas de bases de données en mémoire pour les tests





Avec une base en mémoire, vous testez dans un environnement différent où votre code sera exécuté.



En utilisant une base en mémoire ( H2 , HSQLDB , Fongo ) pour les tests, vous sacrifiez leur fiabilité et leur applicabilité. Ces bases de données se comportent souvent différemment et produisent des résultats différents. Un tel test peut réussir, mais ne garantit pas le bon fonctionnement de l'application en production. De plus, vous pouvez facilement vous retrouver dans une situation où vous ne pouvez pas utiliser ou tester certains comportements ou caractéristiques de votre base, car ils ne sont pas implémentés dans la base de données en mémoire ou se comportent différemment.



Solution: utilisez la même base de données qu'en fonctionnement réel. Bibliothèque Wonderful Testcontainers fournit une API riche pour les applications Java qui vous permet de gérer les conteneurs directement à partir de votre code de test.



Java / JVM



Utilisation -noverify -XX:TieredStopAtLevel=1



Ajoutez toujours des options JVM -noverify -XX:TieredStopAtLevel=1à votre configuration pour exécuter des tests. Cela vous fera gagner 1 à 2 secondes au démarrage de la machine virtuelle avant d'exécuter les tests. Ceci est particulièrement utile dans les premiers jours de vos tests, lorsque vous les exécutez souvent à partir de l'EDI.



Veuillez noter que depuis que Java 13 est -noverifyobsolète.



Conseil: ajoutez ces arguments au modèle de configuration «JUnit» dans IntelliJ IDEA afin de ne pas avoir à le faire à chaque fois que vous créez un nouveau projet.







Utiliser AssertJ



AssertJ est une bibliothèque extrêmement puissante et mature avec une API riche et sécurisée, ainsi qu'un large éventail de fonctions de validation de valeur et de messages d'erreur de test informatifs. De nombreuses fonctions de validation pratiques évitent au programmeur de devoir décrire une logique complexe dans le corps des tests, ce qui lui permet de rendre les tests concis. Par exemple:



assertThat(actualProduct)
        .isEqualToIgnoringGivenFields(expectedProduct, "id");

assertThat(actualProductList).containsExactly(
        createProductDTO("1", "Smartphone", 250.00),
        createProductDTO("1", "Smartphone", 250.00)
);

assertThat(actualProductList)
        .usingElementComparatorIgnoringFields("id")
        .containsExactly(expectedProduct1, expectedProduct2);

assertThat(actualProductList)
        .extracting(Product::getId)
        .containsExactly("1", "2");

assertThat(actualProductList)
        .anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));

assertThat(actualProductList)
        .filteredOn(product -> product.getCategory().equals("Smartphone"))
        .allSatisfy(product -> assertThat(product.isLiked()).isTrue());


Évitez d'utiliser assertTrue()etassertFalse()



Utilisation de messages d'erreur de test simples assertTrue()ou assertFalse()cryptiques:



// 
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);

expected: <true> but was: <false>


Utilisez plutôt les appels AssertJ, qui renvoient des messages clairs et informatifs prêts à l'emploi.



// 
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);

Expecting:
 <[Product[id=1, name='Samsung Galaxy']]>
to contain:
 <[Product[id=2, name='iPhone']]>
but could not find:
 <[Product[id=2, name='iPhone']]>


Si vous devez vérifier la valeur booléenne, rendez le message plus as()descriptif avec la méthode AssertJ.



Utiliser JUnit5



JUnit5 est une excellente bibliothèque pour les tests (unitaires). Il est en développement constant et fournit au programmeur de nombreuses fonctionnalités utiles, telles que des tests paramétrés, des regroupements, des tests conditionnels, le contrôle du cycle de vie.



Utiliser des tests paramétrés



Les tests paramétrés vous permettent d'exécuter le même test avec un ensemble de valeurs d'entrée différentes. Cela vous permet de vérifier plusieurs cas sans écrire de code supplémentaire. En JUnit5 pour cela est les excellents outils @ValueSource, @EnumSource, @CsvSourceet @MethodSource.



// 
@ParameterizedTest
@ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"])
public void rejectedInvalidTokens(String invalidToken) {
    client.perform(get("/products").param("token", invalidToken))
            .andExpect(status().is(400))
}

@ParameterizedTest
@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])
public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {
    // ...
}


Je recommande vivement de tirer le meilleur parti de cette astuce car elle vous permet de tester plus de cas avec un minimum d'effort.



Enfin, je souhaite attirer votre attention sur @CsvSourceet @MethodSource, qui peut être utilisé pour des paramétrages plus complexes, où vous devez également contrôler le résultat: vous pouvez le passer dans l'un des paramètres.



@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "5, 3, 8",
    "10, -20, -10"
})
public void add(int summand1, int summand2, int expectedSum) {
    assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);
}


@MethodSourceparticulièrement efficace en conjonction avec un objet de test séparé contenant tous les paramètres souhaités et les résultats attendus. Malheureusement, en Java, la description de telles structures de données (appelées POJO) est très lourde. Par conséquent, je vais donner un exemple en utilisant les classes de données Kotlin.



data class TestData(
    val input: String?,
    val expected: Token?
)

@ParameterizedTest
@MethodSource("validTokenProvider")
fun `parse valid tokens`(data: TestData) {
    assertThat(parse(data.input)).isEqualTo(data.expected)
}

private fun validTokenProvider() = Stream.of(
    TestData(input = "1511443755_2", expected = Token(1511443755, "2")),
    TestData(input = "151175_13521", expected = Token(151175, "13521")),
    TestData(input = "151144375_id", expected = Token(151144375, "id")),
    TestData(input = "15114437599_1", expected = Token(15114437599, "1")),
    TestData(input = null, expected = null)
)


Tests de groupe



L'annotation @Nestedde JUnit5 est pratique pour regrouper les méthodes de test. Logiquement, il est logique de regrouper certains types de tests (tels que InputIsXY, ErrorCases) ou de rassembler dans votre groupe chaque méthode de test ( GetDesignet UpdateDesign).



public class DesignControllerTest {
    @Nested
    class GetDesigns {
        @Test
        void allFieldsAreIncluded() {}
        @Test
        void limitParameter() {}
        @Test
        void filterParameter() {}
    }
    @Nested
    class DeleteDesign {
        @Test
        void designIsRemovedFromDb() {}
        @Test
        void return404OnInvalidIdParameter() {}
        @Test
        void return401IfNotAuthorized() {}
    }
}






Noms de test lisibles avec @DisplayNameou contre-citations dans Kotlin



En Java, vous pouvez utiliser l'annotation @DisplayNamepour donner à vos tests des noms plus lisibles.



public class DisplayNameTest {
    @Test
    @DisplayName("Design is removed from database")
    void designIsRemoved() {}
    @Test
    @DisplayName("Return 404 in case of an invalid parameter")
    void return404() {}
    @Test
    @DisplayName("Return 401 if the request is not authorized")
    void return401() {}
}






Dans Kotlin, vous pouvez utiliser des noms de fonctions avec des espaces à l'intérieur en les entourant de guillemets simples. De cette façon, vous obtenez une lisibilité des résultats sans redondance de code.



@Test
fun `design is removed from db`() {}


Simuler des services externes



Pour tester les clients HTTP, nous devons simuler les services auxquels ils accèdent. J'utilise souvent MockWebServer d'OkHttp à cette fin . Les alternatives sont WireMock ou Mockserver de Testcontainers .



MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
        .addHeader("Content-Type", "application/json")
        .setBody("{\"name\": \"Smartphone\"}"));

ProductDTO productDTO = client.retrieveProduct("1");

assertThat(productDTO.getName()).isEqualTo("Smartphone");


Utilisez Awaitility pour tester le code asynchrone



Awaitility est une bibliothèque pour tester du code asynchrone. Vous pouvez spécifier le nombre de tentatives de vérification du résultat avant de déclarer un test infructueux.



private static final ConditionFactory WAIT = await()
        .atMost(Duration.ofSeconds(6))
        .pollInterval(Duration.ofSeconds(1))
        .pollDelay(Duration.ofSeconds(1));

@Test
public void waitAndPoll(){
    triggerAsyncEvent();
    WAIT.untilAsserted(() -> {
        assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
    });
}


Pas besoin de résoudre les dépendances DI (Spring)



L'initialisation du framework DI prend quelques secondes avant que les tests puissent démarrer. Cela ralentit la boucle de rétroaction, en particulier dans les premiers stades de développement.



Par conséquent, j'essaie de ne pas utiliser DI dans les tests d'intégration, mais de créer manuellement les objets nécessaires et de les «lier» ensemble. Si vous utilisez l'injection de constructeur, c'est le plus simple. En règle générale, dans vos tests, vous validez la logique métier et vous n'avez pas besoin de DI pour cela.



De plus, depuis la version 2.2, Spring Boot prend en charge l'initialisation paresseuse des beans, ce qui accélère considérablement les tests utilisant DI.



Votre code doit être testable



N'utilisez pas d'accès statique. Jamais



L'accès statique est un anti-pattern. Premièrement, il obscurcit les dépendances et les effets secondaires, rendant le code entier difficile à lire et sujet à des erreurs subtiles. Deuxièmement, l'accès statique empêche les tests. Vous ne pouvez plus remplacer des objets, mais dans les tests, vous devez utiliser des simulations ou des objets réels avec une configuration différente (par exemple, un objet DAO pointant vers la base de données de test).



Au lieu d'accéder au code de manière statique, placez-le dans une méthode non statique, instanciez la classe et transmettez l'objet résultant au constructeur.



// 
public class ProductController {
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = ProductDAO.getProducts();
        return mapToDTOs(products);
    }
}



// 
public class ProductController {
    private ProductDAO dao;
    public ProductController(ProductDAO dao) {
        this.dao = dao;
    }
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = dao.getProducts();
        return mapToDTOs(products);
    }
}


Heureusement, les frameworks DI comme Spring fournissent des outils qui rendent l'accès statique inutile en créant et liant automatiquement des objets sans notre implication.



Paramétrer



Toutes les parties pertinentes de la classe doivent être configurables du côté test. Ces paramètres peuvent être transmis au constructeur de classe.



Imaginez, par exemple, que votre DAO a une limite fixe de 1000 objets par requête. Pour vérifier cette limite, vous devrez ajouter 1001 objets à la base de données de test avant le test. En utilisant l'argument constructeur, vous pouvez rendre cette valeur personnalisable: en production, laissez 1000, en test, réduisez à 2. Ainsi, pour vérifier le travail de la limite, il vous suffira d'ajouter 3 enregistrements à la base de test.



Utiliser l'injection de constructeur



L'injection de champ est mauvaise et conduit à une mauvaise testabilité du code. Vous devez initialiser DI avant les tests ou faire une magie de réflexion étrange. Par conséquent, il est préférable d'utiliser l'injection de constructeur pour contrôler facilement les objets dépendants pendant les tests.



En Java, vous devez écrire un petit code supplémentaire:



// 
public class ProductController {

    private ProductDAO dao;
    private TaxClient client;

    public ProductController(ProductDAO dao, TaxClient client) {
        this.dao = dao;
        this.client = client;
    }
}


Dans Kotlin, la même chose est écrite de manière beaucoup plus concise:



// 
class ProductController(
    private val dao: ProductDAO,
    private val client: TaxClient
){
}


Ne pas utiliser Instant.now() ounew Date()



Vous n'avez pas besoin d'obtenir l'heure actuelle par des appels Instant.now()ou new Date()dans le code de production si vous souhaitez tester ce comportement.



// 
public class ProductDAO {
    public void updateDateModified(String productId) {
        Instant now = Instant.now(); // !
        Update update = Update()
            .set("dateModified", now);
        Query query = Query()
            .addCriteria(where("_id").eq(productId));
        return mongoTemplate.updateOne(query, update, ProductEntity.class);
    }
}


Le problème est que le temps nécessaire ne peut pas être contrôlé par le test. Vous ne pourrez pas comparer le résultat obtenu avec une valeur spécifique, car elle est toujours différente. Utilisez plutôt une classe Clockde Java.



// 
public class ProductDAO {
    private Clock clock; 

    public ProductDAO(Clock clock) {
        this.clock = clock;
    }

    public void updateProductState(String productId, State state) {
        Instant now = clock.instant();
        // ...
    }
}


Dans ce test, vous pouvez créer un objet fictif pour Clock, le transmettre à ProductDAOet configurer l'objet fictif pour qu'il renvoie en même temps. Après les appels, updateProductState()nous pourrons vérifier que la valeur que nous avons spécifiée est bien entrée dans la base de données.



Séparer l'exécution asynchrone de la logique réelle



Le test du code asynchrone est délicat. Les bibliothèques comme Awaitility sont d'une grande aide, mais le processus est toujours compliqué et nous pourrions nous retrouver avec un test clignotant. Il est judicieux de séparer la logique métier (généralement synchrone) et le code d'infrastructure asynchrone, si possible.



Par exemple, en plaçant la logique métier dans ProductController, nous pouvons facilement la tester de manière synchrone. Toute logique asynchrone et parallèle restera dans le ProductScheduler, qui peut être testé de manière isolée.



// 
public class ProductScheduler {

    private ProductController controller;

    @Scheduled
    public void start() {
        CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
        CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
        String usResult = usFuture.get();
        String germanyResult = germanyFuture.get();
    }
}


Kotlin



Mon article Meilleures pratiques pour les tests unitaires dans Kotlin contient de nombreuses techniques de tests unitaires spécifiques à Kotlin. (Note traduction: écrivez dans les commentaires si vous êtes intéressé par la traduction russe de cet article).



All Articles