Test d’un applicatif réseau avec pytest et les espaces de noms Linux

Vincent Bernat

Ini­tié en 2008, lldpd est une im­plé­men­ta­tion en C du stan­dard IEEE 802.1AB-2005 (aussi connu comme LLDP). Bien qu’il soit ac­com­pa­gné de quelques tests uni­taires, comme beau­coup d’autres ap­pli­ca­tifs ré­seaux, la cou­ver­ture de ceux-ci est as­sez res­treinte : ils sont plu­tôt dif­fi­ciles à écrire en rai­son de l’as­pect im­pé­ra­tif du code et du cou­plage fort avec le sys­tème. Une ré­écri­ture (ité­ra­tive ou com­plète) ai­de­rait à rendre le code plus simple à tes­ter, mais cela né­ces­si­te­rait un ef­fort im­por­tant et in­tro­dui­rait de nou­veaux bugs opé­ra­tionnels.

Afin d’ob­te­nir une meilleure cou­ver­ture des tests, les fonc­tion­na­li­tés les plus im­por­tantes de lldpd sont dé­sor­mais vé­ri­fiées à tra­vers des tests d’in­té­gra­tion. Ceux-ci se re­posent sur l’uti­li­sa­tion des es­paces de noms Li­nux pour mettre en place des en­vi­ron­ne­ments iso­lés lé­gers pour chaque test. py­test est uti­lisé comme ou­til de test.

pytest en bref#

py­test est un ou­til de tests pour Py­thon dont la ver­sa­ti­lité per­met son usage dans de nom­breuses si­tua­tions. Il dis­pose de trois fonc­tion­na­li­tés re­mar­quables :

  • l’uti­li­sa­tion du mot-clé assert ;
  • l’in­jec­tion des fix­tures dans les fonc­tions de test ;
  • la pa­ra­mé­tri­sa­tion des tests.

Les assertions#

Avec unit­test, l’ou­til de tests uni­taires fourni avec Py­thon, les tests sont en­cap­su­lés dans une classe et doivent faire ap­pel à des mé­thodes dé­diées pour les as­ser­tions. Par exemple :

class testArithmetics(unittest.TestCase):
    def test_addition(self):
        self.assertEqual(1 + 3, 4)

Avec py­test, il est pos­sible d’ex­pri­mer ceci plus na­tu­rellement :

def test_addition():
    assert 1 + 3 == 4

py­test va ana­ly­ser l’AST et af­fi­cher des mes­sages d’er­reur ap­pro­priés en cas d’échec. Pour plus d’in­for­ma­tions, ré­fé­rez-vous à l’ar­ticle de Ben­ja­min Peterson.

Les fixtures#

Une fix­ture est un en­semble d’ac­tions à ef­fec­tuer afin de pré­pa­rer le sys­tème à exé­cu­ter une sé­rie de tests. Avec les ou­tils clas­siques, il n’est sou­vent pos­sible de dé­fi­nir qu’une seule fix­ture pour un en­semble de tests :

class testInVM(unittest.TestCase):

    def setUp(self):
        self.vm = VM('Test-VM')
        self.vm.start()
        self.ssh = SSHClient()
        self.ssh.connect(self.vm.public_ip)

    def tearDown(self):
        self.ssh.close()
        self.vm.destroy()

    def test_hello(self):
        stdin, stdout, stderr = self.ssh.exec_command("echo hello")
        stdin.close()
        self.assertEqual(stderr.read(), b"")
        self.assertEqual(stdout.read(), b"hello\n")

Dans l’exemple ci-des­sus, nous vou­lons tes­ter quelques com­mandes sur une ma­chine vir­tuelle. La fix­ture dé­marre la VM et ini­tie la connexion SSH. Tou­te­fois, en cas d’échec de cette der­nière, la mé­thode tearDown() ne sera pas ap­pe­lée et la VM conti­nuera de tourner.

Avec py­test, il est pos­sible de faire les choses dif­fé­remment :

@pytest.yield_fixture
def vm():
    r = VM('Test-VM')
    r.start()
    yield r
    r.destroy()

@pytest.yield_fixture
def ssh(vm):
    ssh = SSHClient()
    ssh.connect(vm.public_ip)
    yield ssh
    ssh.close()

def test_hello(ssh):
    stdin, stdout, stderr = ssh.exec_command("echo hello")
    stdin.close()
    stderr.read() == b""
    stdout.read() == b"hello\n"

La pre­mière fix­ture dé­marre une VM. La se­conde va four­nir une connexion SSH vers la VM four­nie en ar­gu­ment. Les fix­tures sont uti­li­sées à tra­vers un sys­tème d’in­jec­tion des dé­pen­dances : la seule pré­sence de leur nom dans la si­gna­ture d’une fonc­tion de test ou d’une autre fix­ture suf­fit à l’uti­li­ser. Chaque fix­ture ne gère le cycle de vie que d’une seule en­tité. Peu im­porte si une autre fix­ture ou une fonc­tion de tests dé­pen­dant de celle-ci réus­sit ou non, la VM sera dé­man­te­lée en fin de test.

La paramétrisation#

Si un test doit être exé­cuté plu­sieurs fois avec des pa­ra­mètres dif­fé­rents, la so­lu­tion clas­sique est d’uti­li­ser une boucle ou de dé­fi­nir dy­na­mi­que­ment les fonc­tions de test. Avec py­test, vous pou­vez pa­ra­mé­tri­ser une fonc­tion de test ou une fixture :

@pytest.mark.parametrize("n1, n2, expected", [
    (1, 3, 4),
    (8, 20, 28),
    (-4, 0, -4)])
def test_addition(n1, n2, expected):
    assert n1 + n2 == expected

Tester lldpd#

Tes­ter une fonc­tion­na­lité de lldpd se fait en cinq étapes :

  1. Mettre en place deux es­paces de noms.
  2. Créer un lien vir­tuel entre ceux-ci.
  3. Dé­mar­rer un pro­ces­sus lldpd dans chaque espace.
  4. Tes­ter la fonc­tion­na­lité dans un des espaces.
  5. Vé­ri­fier avec lldpcli le ré­sul­tat at­tendu dans l’autre espace.

Voici un test ty­pique uti­li­sant les fonc­tion­na­li­tés les plus in­té­res­santes de pytest :

@pytest.mark.skipif('LLDP-MED' not in pytest.config.lldpd.features,
                    reason="LLDP-MED not supported")
@pytest.mark.parametrize("classe, expected", [
    (1, "Generic Endpoint (Class I)"),
    (2, "Media Endpoint (Class II)"),
    (3, "Communication Device Endpoint (Class III)"),
    (4, "Network Connectivity Device")])
def test_med_devicetype(lldpd, lldpcli, namespaces, links,
                        classe, expected):
    links(namespaces(1), namespaces(2))
    with namespaces(1):
        lldpd("-r")
    with namespaces(2):
        lldpd("-M", str(classe))
    with namespaces(1):
        out = lldpcli("-f", "keyvalue", "show", "neighbors", "details")
        assert out['lldp.eth0.lldp-med.device-type'] == expected

Tout d’abord, ce test ne sera exé­cuté que si le sup­port de LLDP-MED a été in­clus dans lldpd. De plus, le test est pa­ra­mé­tré : quatre tests dis­tincts se­ront ef­fec­tués, un pour chaque rôle que lldpd doit être ca­pable d’as­su­mer en tant que ter­mi­nai­son LLDP-MED.

La si­gna­ture du test com­porte quatre pa­ra­mètres non cou­verts par le dé­co­ra­teur parametrize() : lldpd, lldpcli, namespaces et links. Il s’agit des fixtures.

  • lldpd est une fa­brique qui per­met de lan­cer des ins­tances de lldpd. Elle as­sure la confi­gu­ra­tion de l’es­pace de noms (mise en place de la sé­pa­ra­tion de pri­vi­lèges, uni­for­mi­sa­tion de cer­tains fi­chiers, …) puis ap­pelle lldpd avec les pa­ra­mètres ad­di­tion­nels four­nis. Les mes­sages émis par le dé­mon sont en­re­gis­trés dans le rap­port en cas d’er­reur. Le mo­dule se charge aussi de four­nir un ob­jet pytest.config.lldpd qui contient les fonc­tion­na­li­tés sup­por­tées par lldpd afin de sau­ter les tests qui né­ces­sitent une fonc­tion­na­lité non dis­po­nible. Le fi­chier fixtures/programs.py contient da­van­tage de détails.

  • lldpcli est éga­le­ment une fa­brique, mais pour lan­cer des ins­tances de lldpcli, le client pour in­ter­ro­ger lldpd. De plus, la sor­tie pro­duite est trai­tée pour ob­te­nir un dic­tion­naire et fa­ci­li­ter l’écri­ture des tests.

  • namespaces est la fix­ture la plus in­té­res­sante. Il s’agit d’une fa­brique pour les es­paces de noms Li­nux. Elle va créer un nou­vel es­pace de noms ou ré­fé­ren­cer un es­pace exis­tant. Il est pos­sible d’en­trer dans un es­pace donné avec le mot-clé with. La fa­brique main­tient pour chaque es­pace une liste de des­crip­teurs de fi­chiers sur les­quels exé­cu­ter setns(). Une fois le test fini, les es­paces de noms sont dé­truits na­tu­rel­le­ment du fait de la fer­me­ture de tous les des­crip­teurs de fi­chiers. Le fi­chier fixtures/namespaces.py contient da­van­tage de dé­tails. Cette fix­ture est réuti­li­sable par d’autres projets1.

  • links contient des fonc­tions pour gé­rer les in­ter­faces ré­seau : créa­tion d’une paire d’in­ter­faces Ether­net entre deux es­paces de noms, créa­tion de ponts, d’agré­gats et de VLAN, etc. Il se re­pose sur le mo­dule pyroute2. Le fi­chier fixtures/network.py contient da­van­tage de détails.

Vous pou­vez dé­cou­vrir un exemple d’exé­cu­tion de ces tests en re­gar­dant le ré­sul­tat ob­tenu avec Tra­vis pour la ver­sion 0.9.2. Chaque test étant isolé, il est pos­sible de les lan­cer en pa­ral­lèle avec pytest -n 10 --boxed. Afin de dé­pis­ter en­core plus de bugs, à la com­pi­la­tion, l’ad­dress sa­ni­ti­zer (ASAN) et le un­de­fi­ned be­ha­vior sa­ni­ti­zer (UBSAN) sont ac­ti­vés. En cas de pro­blème dé­tecté, comme par exemple une fuite mé­moire, le pro­gramme s’ar­rê­tera avec un code de sor­tie non nul et le test as­so­cié échouera.