Choses à faire

24h dans une journée, et tant de choses à faire !

Applications et bases de données, comment tester ?

publié le 17 décembre 2009 par Killian Ebel

Même si l’on préfèrerait l’éviter, les tests unitaires d’une application web par exemple, portent souvent sur des parties du programme accédant à une base de données. Bien qu’il existe plusieurs outils servant à simplifier la tâche, voici tout d’abord des bonnes pratiques que je conseillerais à tout développeur.

Bien séparer les couches

Même si cela peut paraître idiot de le répéter encore une fois, il est indubitable que moins votre code concernera les accès à la base, moins il y aura de tests à faire en ce sens. Ainsi, concentrez ces parties par exemple dans des DAO, très loin des partie « IHM » et « Business » de votre application.

Utilisez une base de données embarquée

Je ne sais pas si c’est réellement une bonne pratique, mais il me semble idéal pour l’indépendance des tests qu’ils puissent être lancés n’importe où / n’importe quand, même quand le SGBD réel n’est pas actif. Ainsi, des systèmes tels que sqlite, HSQLDB ou d’autres de la même catégorie sont à mon goût plus adaptés pour les tests.

Évitez au maximum les dépendances

Analysez ces deux classes et imaginez la plus pratique à tester :

public class UserDAOImpl implements UserDAO {

    protected Connection getConnection() throws SQLException {
        return DriverManager.getConnection("jdbc://...", "login", ...);
    }

    // ... createUser ...
}
public class UserDAOImpl implements UserDAO {
    private Connection connection;

    public UserDAOImpl(Connection connection) {
        this.connection = connection;
    }

    // ... createUser ...
}

Voici la méthode createUser (JDBC) :

public User createUser(String userId, String firstName, String lastName) throws DAOException {
    try {
        PreparedStatement ps = getConnection().prepareStatement("INSERT INTO ...");
        ps.setString(1, userId);
        ps.setString(2, firstName);
        ps.setString(3, lastName);
        ps.executeUpdate();
        ps.close();
        return new User(userId, firstName, lastName);
    } catch (SQLException e) {
        throw new DAOException(e.getMessage());
    }
}

On voit rapidement que le premier code est directement dépendant du DriverManager, en gérant lui-même la récupération de la connexion courante. Dans le deuxième cas, on injecte la connexion dans le DAO, c’est à dire plus de dépendance directe, et beaucoup plus facile à tester. Pour mieux découpler, un bon Framework d’injection de dépendance vous aidera à rendre vos classes autonomes.

Les différentes approches

L’approche par les Mocks / Fakes

Bien que très pratiques, les Mocks peuvent ne pas être appropriés pour les tests d’accès à la base, certains les déconseillent même vivement.

Les avantages sont une vitesse d’exécution bien supérieure, en plus de pouvoir tester aisément les cas les plus extrêmes en forçant les exceptions. En contrepartie, cela représente beaucoup de code à écrire (tous les objets factices à implémenter), et de plus si la structure des tables venait à changer, les tests pourraient ne pas échouer alors qu’ils ne seraient plus adaptés sémantiquement.

En fonction de la testabilité de votre code, l’écriture des Mocks peut s’avérer très compliquée. Si votre code viole la loi de Déméter, vous aurez à créer des Mocks / Fakes à la chaîne ; par exemple dans l’exemple précédent (qui est un mauvais exemple !), la méthode createUser, l’appel à getConnection() n’est utilisé que pour instancier un PreparedStatement

final MockConnection connection = new MockConnection();
expect(mock.prepareStatement("INSERT..."));
...

final UserDAO dao = new UserDAOImpl(connection);
dao.createUser(...);
...

L’approche « Sandbox »

Cette approche très différente consiste à premièrement, utiliser une base de test différente de la base de production. Les avantages sont évidents, mais les défauts aussi : très lourd à maintenir, il faut rétablir l’état initial de la base entre chaque test pour garder leur indépendance, ce qui rend l’exécution d’une suite de tests très lente (par expérience, il est pénible d’attendre 10 – 15 minutes pour avoir les résultats d’une suite de tests).

Une alternative à cette méthode est de travailler en mode transactionnel et de faire un Rollback après chaque test, pour que les données n’y persistent pas ; je trouve l’idée intéressante, mais il faut faire attention car si le test lance une erreur imprévue et que le Rollback n’est pas exécuté, il faussera la suite des tests en semant la zizanie au sein des données…

Enfin, toujours dans l’idée de rendre les tests indépendants du moment et du lieu d’exécution, cette approche utilise fréquemment le concept de « Dataset » (ou Fixtures). Selon l’outil utilisé, la façon de charger les données de test sera différente mais l’idée reste la même.

Voici une liste d’outils dont le but est de faciliter ce type de tests :

DbUnit (Java)

Le Framework de référence (où en tout cas, celui sur lequel les autres se basent).

Il fonctionne sur un principe de Dataset XML. Deux types de Dataset existent, le XmlDataSet et le FlatXmlDataSet, respectivement :

<!DOCTYPE dataset SYSTEM "dataset.dtd">
<dataset>
    <table name="USERS">
        <column>USER_ID</column>
        <column>FIRST_NAME</column>
        <column>LAST_NAME</column>
        <row>
            <value>1</value>
            <value>Foo</value>
            <value>Bar</value>
        </row>
        <row>
            ...
        </row>
    </table>
</dataset>

et :

<!DOCTYPE dataset SYSTEM "users-dataset.dtd">
<dataset>
    <USERS USER_ID="1" FIRST_NAME="Foo" LAST_NAME="Bar" />
    <USERS ... />
</dataset>

Le XmlDataSet est ainsi bien plus verbeux mais plus généraliste, alors que le FlatXmlDataSet est plus simple à écrire mais peut être plus fastidieux à mettre en place (il faut créer le DTD de son Dataset).

Voici un exemple basique qui montre une des opérations disponibles :

public class SampleTest extends DBTestCase {

    public SampleTest(String name) {
        super(name);
        // ... configuration de la connection
    }

    protected DatabaseOperation getSetUpOperation() throws Exception {
        // On rafraîchit la base avant chaque test
        return DatabaseOperation.REFRESH;
    }

    protected DatabaseOperation getTearDownOperation() throws Exception {
        // On ne fait rien après un test
        return DatabaseOperation.NONE;
    }

    public void testAjoutMembre() {
        UserDAO dao = ...;
        dao.createUser(...)

        // On récupère l'état courant de la table sous forme de dataset
        IDataSet databaseDataSet = getConnection().createDataSet();
        ITable actualTable = databaseDataSet.getTable("USERS");

        // On le compare à un dataset XML
        IDataSet expectedDataSet = new FlatXmlDataSet(new File("expectedDataSet.xml"));
        ITable expectedTable = expectedDataSet.getTable("USERS");

        Assertion.assertEquals(expectedTable, actualTable);
    }
}

DbUnit et Spring (Java)

Spring, l’ingénieux Framework d’Inversion de Contrôle (et de plein d’autres choses !), permet de simplifier l’utilisation de DbUnit. Spring et les annotations rendent conjointement le test vraiment agréable à écrire, jugez-en par vous même.

Voici le fichier applicationContext.xml avec la configuration de la DataSource :

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN"
    "http://www.springframework.org/dtd/spring-beans.dtd">

<beans>
    <bean id="dataSource" destroy-method="close">
        <property name="driverClassName" value="oracle.jdbc.driver.OracleDriver" />
        <property name="url" value="jdbc:oracle:thin:@10.0.0.2:1521:orcl" />
        <property name="username" value="your_user_name" />
        <property name="password" value="your_password" />
    </bean>
</beans>

Et le test unitaire :

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations={"classpath:applicationContext.xml"})
public class SampleTestWithSpring {

    @Autowired
    private DataSource dataSource;

    @Before
    public void init() throws Exception {
        // Avant le test on insère le dataSet
        DatabaseOperation.CLEAN_INSERT.execute(dataSource.getConnection(), getDataSet());
    }

    @After
    public void after() throws Exception {
        // Après le test on supprime le dataSet
        DatabaseOperation.DELETE_ALL.execute(dataSource.getConnection(), getDataSet());
    }

    private IDataSet getDataSet() throws Exception {
        return new FlatXmlDataSet(new File("src/test/resources/dataset.xml"));
    }

    @Test
    public void testXXX() {
        ...
    }
}

On pourra ajouter que Spring contient à lui seul un module pour les tests d’intégration.

Unitils (Java)

Déjà évoqué concernant l’utilisation des Mocks, Unitils permet de simplifier les tests d’accès à la base de données.

public class UserDAOTest extends UnitilsJUnit4 {

    @Test
    @DataSet("UserDAOTest.testFindByMinimalAge.xml")
    public void testFindByMinimalAge() {
        List<User> result = userDao.findByMinimalAge(18);
        assertPropertyLenientEquals("firstName", Arrays.asList("jack"), result);
    }
}

Le fichier dataset est un fichier XML similaire à ceux utilisés par DbUnit. J’avoue préférer la syntaxe d’Unitils à celle de DbUnit, car l’utilisation des annotations (par exemple @ExpectedDataSet ou @Transactional(TransactionMode.ROLLBACK)) combinée à Spring rendent ce framework extrêment flexible.

PHPUnit Database Extension (PHP)

Cette extension de PHPUnit est en fait un portage de DbUnit en PHP. Il utilise ainsi le même principe des dataset.xml :

require_once 'PHPUnit/Extensions/Database/TestCase.php';

class BankAccountDBTest extends PHPUnit_Extensions_Database_TestCase {

    protected $pdo;

    public function __construct() {
        $this->pdo = new PDO('sqlite::memory:');
    }

    protected function getConnection() {
        return $this->createDefaultDBConnection($this->pdo, 'sqlite');
    }

    protected function getSetUpOperation() {
        return $this->getOperations()->CLEAN_INSERT();
    }

    protected function getTearDownOperation() {
        return $this->getOperations()->DELETE_ALL();
    }

    protected function getDataSet() {
        return $this->createFlatXMLDataSet(dirname(__FILE__).'/_files/users-dataset.xml');
    }

    public function testNewUserCreation() {
        $user = new User(...);
        // ... enregistrement ...

        $expected_dataset = $this->createFlatXMLDataSet(dirname(__FILE__).'/_files/expected-users-dataset.xml');
        $this->assertDataSetsEqual($xml_dataset, $this->getConnection()->createDataSet());
    }
}

Doctrine ORM (PHP)

Ayant pas mal utilisé cet outil dans divers projets PHP, j’ai cherché par curiosité s’il proposait une façon de gérer les tests unitaires. Effectivement, il propose quelques raccourcis via la classe Doctrine_UnitTestCase, mais sans entrer dans le détail j’ai l’impression que les possibilités sont malheureusement moins grandes qu’avec PHPUnit.

class Doctrine_Sample_TestCase extends Doctrine_UnitTestCase {

    public function prepareTables() {
        $this->tables[] = "Users";
        parent::prepareTables();
    }

    public function prepareData() {
        // Peut-être des fixtures peuvent être chargées ici ?
    }

    public function testNewUserCreation() {
        $this->assertTrue(...);
    }
}

Un point intéressant proposé par Doctrine est la présence des Mock Drivers, qui permettent de simuler le comportement des tests sur un SGBD virtuel (si vous voulez par exemple tester la génération des requêtes pour Oracle) :

class Doctrine_Sample_TestCase extends Doctrine_UnitTestCase {

    public function testInit() {
        $this->dbh = new Doctrine_Adapter_Mock('oracle');
        $this->conn = Doctrine_Manager::getInstance()->openConnection($this->dbh);
    }

    public function testMockDriver() {
        $user = new User();
        $user->username = 'jwage';
        $user->password = 'changeme';
        $user->save();

        $sql = $this->dbh->getAll();

        $this->assertEqual($sql[0], 'INSERT INTO user (username, password) VALUES (?, ?)');
    }
}

NDbUnit (.Net)

Un projet pour .Net sous licence LGPL écrit en C#. Le fonctionnement est assez similaire à DbUnit ; d’après la documentation, on peut facilement le combiner à Proteus afin de simplifier l’écriture du code et d’éviter les redondances.

Voici un exemple de Dataset (UsersDataSet.xml) :

<?xml version="1.0" encoding="utf-8" ?>
<MyDataset xmlns="http://tempuri.org/UsersDataSet.xsd">
    <Customer>
        <CustomerId>1</CustomerId>
        <Firstname>John</Firstname>
        <Lastname>Doe</Lastname>
    </Customer>
    <Customer>
        <CustomerId>2</CustomerId>
        <Firstname>Sam</Firstname>
        <Lastname>Smith</Lastname>
    </Customer>
</MyDataset>

Et un exemple de test :

[TestFixture]
public class Tests
{
    private string _connectionString;
    private NDbUnit.Core.INDbUnitTest _mySqlDatabase;

    [SetUp]
    public void _Setup()
    {
        _mySqlDatabase.PerformDbOperation(NDbUnit.Core.DbOperationFlag.CleanInsertIdentity);
    }

    [FixtureSetUp]
    public void _TestFixtureSetup()
    {
        _connectionString = "server=localhost;user=dbuser;password=dbpassword;initial catalog=MyDatabase;";
        _mySqlDatabase = new NDbUnit.Core.SqlClient.SqlDbUnitTest(_connectionString);

        _mySqlDatabase.ReadXmlSchema(@"..\..\UsersDataSet.xsd");
        _mySqlDatabase.ReadXml(@"..\..\UsersDataSet.xml");
    }

    [FixtureTearDown]
    public void _TestFixtureTearDown()
    {
        _mySqlDatabase.PerformDbOperation(NDbUnit.Core.DbOperationFlag.DeleteAll);
    }

    [Test]
    public void Test()
    {
        CustomerRepository repository = new CustomerRepository();
        Assert.AreEqual(2, repository.GetAllCustomers().Count);
    }
}

Ruby On Rails (Ruby)

Si vous êtes développeur Ruby et utilisez le framework Rails, sachez que ce type de tests est prévu par l’outil : un mécanisme de fixtures YAML ou CSV est inclus dans le projet, qui combiné au moteur de tests unitaires par défaut (ActiveSupport::TestCase) se révèle très complet.

De plus, un ensemble d’assertions vous permettra de tester rapidement vos modèles (ainsi que d’effectuer rapidement des tests d’intégration et fonctionnels, mais c’est un autre sujet !).

Le Framework peut travailler avec d’autres outils comme NullDB, qui permet d’éviter l’utilisation d’une base de données, ou d’autres méchanismes en remplacement des fixtures (Factory Girl, Machinist).

Voici un exemple de dataset (users.yml) :

david:
    name: David Heinemeier Hansson
    birthday: 1979-10-15
    profession: Systems development

steve:
    name: Steve Ross Kellock
    birthday: 1974-09-27
    profession: guy with keyboard

Ces fixtures peuvent être rendus dynamiques à l’aide d’ERb.

require 'test_helper'

class PostTest < ActiveSupport::TestCase
    def test_user
        david = users(:david).find
        assert_equals("Systems development", david.profession)
    end
end

C’est un test très basique mais je pense qu’on peut pousser bien plus loin à l’aide du Framework.

Django (Python)

Le puissant Framework Python propose aussi tout l’outillage nécessaire pour tester votre application. En plus des TestCase habituels, vous pourrez créer des TransactionTestCase, qui réinitialisera l’état de la base (de test) avant le lancement des tests en chargeant les données initiales.

De plus, des fixtures peuvent aussi être utilisées, au format JSON ou YAML (ici users.json) :

[
    {
        "model": "myapp.models.User",
        "pk": 1,
        "fields": {
            "first_name": "John",
            "last_name": "Lennon"
        }
    },
    {
        "model": "myapp.models.User",
        "pk": 2,
        "fields": {
            "first_name": "Paul",
            "last_name": "McCartney"
        }
    }
]

Le dataset peut de même être un fichier SQL, même si cela rend vos tests un peu moins indépendants du SGBD utilisé :

INSERT INTO myapp_user (first_name, last_name) VALUES ('John', 'Lennon');
INSERT INTO myapp_user (first_name, last_name) VALUES ('Paul', 'McCartney');

Et voici un exemple de classe de test utilisant ces fixtures :

from django.test import TestCase
from myapp.models import User

class UsersTestCase(TestCase):
    fixtures = ['users.json']

    def testUsers(self):
        john = User.objects.get(pk=1)
        self.assertEquals("John", john.first_name)

Et les procédures stockées ?

Dans beaucoup d’applications, procédures stockées et triggers sont utilisés pour automatiser certaines tâches. Il existe deux méthodes pour les tester : localement, via une autre procédure de test, ou à distance, en appelant la procédure avec votre langage par défaut (Java, Ruby…).

Voici un exemple de procédure stockée à tester :

CREATE OR REPLACE PROCEDURE add (
    a IN NUMBER,
    b IN NUMBER,
    result OUT NUMBER
)
IS
BEGIN
    result := a + b;
END;
/

Pour écrire un test sous forme de procédure stockée, il existe l’outil utPLQSL pour Oracle, mais il n’a plus l’air maintenu depuis quelques années. Je n’ai pas trouvé d’outil similaire pour les autres SGBD, si ce n’est SQLUnit, mais je ne l’ai pas étudié en détail.

CREATE OR REPLACE PACKAGE BODY ut_myapp
IS
    PROCEDURE ut_setup
    IS
    BEGIN
        NULL;
    END;

    PROCEDURE ut_teardown
    IS
    BEGIN
        NULL;
    END;

    PROCEDURE ut_ADD
    IS
        result NUMBER;
    BEGIN
        ADD ( A => 3,  B => 2, RESULT => result );
        utAssert.eq ( 5, result );
    END ut_ADD;

END ut_myapp;
/

Les tests unitaires rattachés aux bases de données permettent d’assurer un peu plus la stabilité de l’application et surtout l’intégrité de ses données. Utilisez-vous un de ces outils ou un outil similaire, et avez-vous rencontré des problèmes lors de sa mise en place ? Quels seraient les conseils à donner à tous les développeurs pour améliorer l’écriture de leur code / tests ?

Quelques références

Les textes, illustrations et démonstrations présents sur ce site sont la propriété de leurs auteurs respectifs, sauf mention contraire (photo de la bannière).
Chosesafaire.fr, un site propulsé par Wordpress, vous est proposé par Pierre Quillery & Killian Ebel.

Valid XHTML 1.0 Strict