Dans deux articles précédents (ici et ici), j’ai plaidé pour l’écriture de tests unitaires qui ciblent la base de données. J’ai fait valoir que les tests qui exécutent une seule instruction SQL à partir d’un backend Java devraient être considérés comme des tests unitaires. Après tout, ils ciblent une unité de comportement isolée et valident une logique souvent complexe dans un langage différent (SQL). Le code Java invoqué par un tel test agit simplement comme une couche de transport. Vous avez besoin d’une base de données en cours d’exécution pour tester correctement un tel code. La moquerie ne fera pas l’affaire et vous ne devriez pas non plus reléguer les requêtes à quelques scénarios globaux au sommet de la pyramide. Ils appartiennent au bas de la pyramide et doivent être nombreux, petits et rapides.
La vitesse est un goulot d’étranglement si vous atteignez une couverture décente du code de la base de données. L’installation, l’exploitation et la maintenance sont coûteuses. Tout code de test comporte une responsabilité de maintenance. Il perd son utilité lorsque l’écosystème qui l’entoure change, c’est-à-dire le deuxième après sa libération. Les tests de base de données portent la charge supplémentaire de provisionner des schémas et des données de test à jour. Pour alléger le fardeau global, regardez toujours les trois éléments suivants : simplicité, efficacité et rapidité.
Simplicité
Si vous impliquez une base de données dans votre suite de tests Java, assurez-vous qu’il s’agit d’une base de données conteneurisée. Le framework Testcontainers prend en charge l’exigence de simplicité. Il ajoute la couche d’abstraction indispensable autour de Docker pour provisionner, démarrer et supprimer un conteneur de votre base de données pendant le cycle de vie de la suite de tests. Et il le fait avec un minimum de plaques chauffantes, en gardant vos tests lisibles.
Cette spécialisation personnalisée du MySQLContainer construit un conteneur basé sur mysql:latest, avec un nouveau schéma HR, un utilisateur ‘docker’ et exécute un script de configuration à partir de src/test/resources/sql
au démarrage du conteneur.
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.MySQLContainer;
public class TestDBContainer extends MySQLContainer {
public OurSQLContainer() {
super("mysql:latest");
withDatabaseName("HR")
.withUsername("docker")
.withPassword("docker")
.withClasspathResourceMapping("sql/setup.sql", "/docker-entrypoint-initdb.d/", BindMode.READ_ONLY);
}
}
Le niveau de classe @TestContainers
l’annotation garantit que le @Container
les champs annotés sont démarrés. Le framework garantit que les tests ne commencent à s’exécuter que lorsque la base de données est prête à recevoir des connexions.
@Testcontainers
class SchemaIntegrityTest {
@Container
static OurSQLContainer CONTAINER = new OurSQLContainer();
@Test
public void someSQLTest(){
Connection conn = CONTAINER.createConnection("");
...
}
}
Efficacité
C’est quelque chose où le framework ne peut pas faire tout le travail à votre place. Une suite de tests efficace ne cible pas deux fois la même fonctionnalité. Cependant, dans une certaine mesure, il est inévitable que le code générique soit appelé plusieurs fois. Imaginez une simple requête pour récupérer un enregistrement d’utilisateur. Cela sera invoqué dans plusieurs scénarios de test. Tout au long du test, il peut être appelé cinquante fois alors que sa fonctionnalité n’a besoin d’être validée qu’une seule fois. C’est du gaspillage.
Imaginez un test qui valide les chemins malheureux dans l’extrait ci-dessous. Nous voulons détecter les exceptions appropriées pour un membre inconnu, un film inconnu, un utilisateur trop jeune et un nombre maximum de locations dépassé. Chaque scénario suivant répète plus de requêtes jusqu’à ce qu’il lève son exception attendue. Et il y aura probablement un autre code de production en cours de test qui récupère les membres et les films par identifiant.
void rentVideo(long memberId, long movieId){
Member member = db.getMemberById(memberId).orElseThrow(() -> new NotFound(“member”));
Movie movie = db.getMovieById(movieId).orElseThrow(() -> new NotFound(“movie”));
if (member.getAge() < movie.getMinimumAge())
throw new AgeInappropriateException();
if (db.getMoviesOnLoan(memberId) >= 3)
throw new MaximumRentalsExceeded();
[…]
}
Le code qui touche la base de données doit être isolé via une interface, comme dans l’exemple ci-dessus. Nous devrions tester les quatre méthodes de cette interface séparément par rapport à une base de données réelle, mais pour les chemins d’exécution possibles de rentVideo, nous devrions utiliser une instance simulée. Cette disposition économise de nombreux appels en double à la base de données et accélère considérablement l’exécution du test, sans dégrader la couverture.
La vitesse
La stratégie d’efficacité élimine les appels inutiles. Un autre coup de pouce d’optimisation peut être obtenu en réutilisant le même conteneur par classe de suite. Comme vous le remarquez, nous faisons notre org.testcontainer.containers.JdbcDatabaseContainer
statique. Si nous ne le faisions pas, un nouveau conteneur serait démarré et détruit pour chaque méthode. Certes, cela rendrait la suite plus déterministe – les tests devraient s’exécuter dans un ordre aléatoire et ne pas s’appuyer sur des changements d’état antérieurs – mais le démarrage du conteneur n’est pas si bon marché. Ce n’est généralement pas un gros problème d’éviter de tels conflits d’État au sein d’une même classe. Si vous voulez faire un peu de nettoyage, un @AfterEach
le rappel fait l’affaire. Je déconseille d’utiliser un seul conteneur pour une suite entière de plusieurs classes. Il n’y a pas de rappel « BeforeEntireSuite » dans TestContainers ou JUNit, vous devez donc envelopper votre conteneur dans votre propre singleton statique et prendre soin de le démarrer et de le fermer vous-même. Pas la peine. Utilisez plutôt une image de base de données préparée pour vraiment accélérer les choses.
Dans notre exemple précédent, nous avons construit une nouvelle image contre mysql:latest, en utilisant le mécanisme mysql standard pour exécuter les scripts de démarrage placés dans /docker-entrypoint-initdb.d
. Pour une base de données de production de grande taille avec plusieurs schémas et des centaines de tables, vues, déclencheurs et procédures stockées, c’est incroyablement inutile à exécuter avant chaque classe de test. De plus, les scripts SQL sont gérés de manière centralisée (ou ils devraient !) et n’appartiennent pas à votre dossier de ressources de test en premier lieu.
FROM mysql:latest
ENV MYSQL_ROOT_PASSWORD=root
ENV MYSQL_DATABASE=hr
ENV MYSQL_USER=docker
ENV MYSQL_PASSWORD=docker
COPY ./setup.sql /docker-entrypoint-initdb.d/
# necessary to persist the new database state when we commit the container
RUN cp -r /var/lib/mysql /var/lib/mysql-no-volume
CMD ["--datadir", "/var/lib/mysql-no-volume"]
Vous devez construire votre base de données de test en tant que fichier de construction Docker, avec tout le schéma de production et éventuellement des données de référence génériques (liste de produits, utilisateurs clés, etc.). Exécutez cette image pour créer les schémas et insérer les données de test, puis validez l’état dans une nouvelle image. Voici l’image que vous utilisez ensuite dans vos tests :
super(DockerImageName.parse("testdb:latest").asCompatibleSubstituteFor("mysql"));
Les conteneurs ont commencé à partir de cette image ignorera le processus d’initialisation de mysql et gagnera plusieurs secondes par conteneur, même avec un schéma minimal. Avec une suite de classes multiples, il s’agit d’un gain de vitesse important.
Pour conclure : gardez votre code de base de données isolé et granulaire. De cette façon, il aura une faible complexité cyclomatique et vous pouvez obtenir une bonne couverture de tous les chemins avec quelques tests unitaires légers. Utilisez une image préparée avec des données de test représentatives pour minimiser le temps de démarrage du conteneur et votre suite sera efficace et rapide.