Expert Chapitre 33-34 / 18

Tester un reconciler en TDD (.NET) & Tests d'intégration : envtest et kind

Écrire des tests unitaires en TDD pour la logique de réconciliation d'un Operator C#/KubeOps, puis valider l'intégration avec envtest et kind.

Tester un reconciler en TDD (.NET)

L’idée en une phrase

Le TDD appliqué à un réconciliateur consiste à écrire d’abord le test qui décrit l’écart attendu entre état désiré et état réel, puis le code qui le comble — le cycle Red-Green-Refactor reflète la boucle observe → diff → agit : le test rouge formule l’écart, le code vert le comble, le refactoring affine la logique de diff.

Analogie : Considérons un architecte qui rédige d’abord le cahier des charges (le test) avant de dessiner les plans (le code). Le cahier décrit précisément l’état désiré du bâtiment : « trois étages, façade en pierre, toiture végétalisée ». L’architecte dessine ensuite les plans qui convergent vers ce cahier. Si les plans ne satisfont pas une exigence, le cahier le signale immédiatement (test rouge). Le TDD d’un reconciler suit la même logique : le test décrit l’écart et la correction attendue, le reconciler converge vers cette spécification.

Points clés

  • Au cœur d’un reconciler se trouve une fonction déterministe : elle reçoit un état désiré (la CR) et un état réel (les ressources du cluster), calcule le diff, et retourne les actions correctrices. Isoler cette logique des dépendances Kubernetes permet de la tester unitairement en quelques millisecondes.
  • En KubeOps, le reconciler implémente IEntityController<T>. Pour tester unitairement, on injecte un mock de IKubernetesClient qui simule l’état réel du cluster. Le test configure le mock (« le Deployment existe avec 3 replicas disponibles »), appelle ReconcileAsync, puis vérifie les appels effectués sur le client (création, mise à jour, suppression).
  • Le cycle TDD pour un reconciler se décompose en trois temps : Red — le test décrit un scénario d’écart (« la CR demande un Secret, aucun n’existe ») et l’action attendue (« le reconciler crée le Secret ») ; Green — le reconciler implémente la logique minimale pour combler cet écart ; Refactor — extraire la logique de diff dans des méthodes nommées, clarifier les concepts métier.
  • Tester l’idempotence (comme vu au chapitre day 23-24) est un cas de test fondamental : appeler ReconcileAsync deux fois de suite avec le même état ne doit produire qu’une seule action — le second appel constate que l’état réel correspond déjà à l’état désiré et ne fait rien.
  • Les tests unitaires vérifient la logique du diff et les actions, mais pas l’interaction réelle avec l’API server — les conflits de resourceVersion, le comportement du server-side apply (chapitre day 27-28) et les erreurs réseau échappent aux mocks.

Exemple concret

On développe un CertificateController qui gère des CR Certificate. L’état désiré déclare un certificat TLS pour un domaine ; le reconciler doit créer un Secret Kubernetes contenant le certificat. Premier test (Red) : fournir une CR Certificate sans Secret correspondant dans le mock → le reconciler doit appeler Create sur le client avec un Secret contenant les clés tls.crt et tls.key. Le test passe au vert en implémentant la création. Deuxième test : le Secret existe déjà avec le bon contenu → le reconciler ne fait rien (idempotence — aucun appel Create ni Update). Troisième test : le Secret existe mais le certificat expire dans moins de 30 jours → le reconciler appelle Update pour renouveler. Chaque test isole un cas de diff précis.

Pyramide de tests d’un Operator

NiveauOutilVitesseCe qu’il vérifieVolume
UnitairexUnit + mocksMillisecondesLogique de diff, idempotence, cas limitesBeaucoup (~80 %)
IntégrationenvtestSecondesCRUD réel, status updates, finalizersQuelques-uns (~15 %)
End-to-endkind30-60 s de setupScheduling, réseau, volumes, cycle de vie completTrès peu (~5 %)

Code C# — tests unitaires TDD d’un reconciler KubeOps

// Tests unitaires du CertificateController — cycle Red-Green-Refactor.
// Chaque test isole un cas de diff entre état désiré et état réel.
public class CertificateControllerTests
{
    private readonly Mock<IKubernetesClient> _clientMock = new();
    private readonly CertificateController _sut;

    public CertificateControllerTests()
    {
        _sut = new CertificateController(
            _clientMock.Object,
            Mock.Of<ILogger<CertificateController>>());
    }

    [Fact]
    public async Task Should_Create_Secret_When_None_Exists()
    {
        // Arrange — état réel : aucun Secret
        var cr = new V1Certificate
        {
            Metadata = new() { Name = "my-cert", NamespaceProperty = "default" },
            Spec = new() { Domain = "api.example.com" }
        };

        _clientMock
            .Setup(c => c.Get<V1Secret>("my-cert-tls", "default"))
            .ReturnsAsync((V1Secret?)null);

        // Act — le reconciler observe l'écart et agit
        await _sut.ReconcileAsync(cr, CancellationToken.None);

        // Assert — un Secret a été créé avec les bonnes clés
        _clientMock.Verify(c => c.Create(
            It.Is<V1Secret>(s =>
                s.Metadata.Name == "my-cert-tls" &&
                s.Data.ContainsKey("tls.crt") &&
                s.Data.ContainsKey("tls.key")),
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Should_Not_Act_When_Secret_Already_Valid()
    {
        // Arrange — état réel = état désiré (Secret valide, certificat non expiré)
        var cr = new V1Certificate
        {
            Metadata = new() { Name = "my-cert", NamespaceProperty = "default" },
            Spec = new() { Domain = "api.example.com" }
        };

        _clientMock
            .Setup(c => c.Get<V1Secret>("my-cert-tls", "default"))
            .ReturnsAsync(CreateValidSecret(expiresInDays: 90));

        // Act — le reconciler observe, calcule un diff nul, ne fait rien
        await _sut.ReconcileAsync(cr, CancellationToken.None);

        // Assert — idempotence : ni Create, ni Update
        _clientMock.Verify(c => c.Create(
            It.IsAny<V1Secret>(), It.IsAny<CancellationToken>()), Times.Never);
        _clientMock.Verify(c => c.Update(
            It.IsAny<V1Secret>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task Should_Renew_When_Certificate_Expires_Soon()
    {
        // Arrange — état réel diverge : le certificat expire dans 15 jours
        var cr = new V1Certificate
        {
            Metadata = new() { Name = "my-cert", NamespaceProperty = "default" },
            Spec = new() { Domain = "api.example.com" }
        };

        _clientMock
            .Setup(c => c.Get<V1Secret>("my-cert-tls", "default"))
            .ReturnsAsync(CreateValidSecret(expiresInDays: 15));

        // Act — l'écart est détecté, le reconciler renouvelle
        await _sut.ReconcileAsync(cr, CancellationToken.None);

        // Assert — Update appelé avec un nouveau certificat
        _clientMock.Verify(c => c.Update(
            It.Is<V1Secret>(s => s.Data.ContainsKey("tls.crt")),
            It.IsAny<CancellationToken>()), Times.Once);
    }
}

Code YAML — la CR Certificate qui décrit l’état désiré

# certificate.yaml — l'état désiré : un certificat TLS pour api.example.com
apiVersion: cert.example.com/v1
kind: Certificate
metadata:
  name: my-cert
  namespace: default
spec:
  domain: api.example.com          # le domaine à protéger
  renewBeforeDays: 30              # seuil de renouvellement anticipé
  issuer: letsencrypt-prod         # l'autorité de certification
# Le reconciler doit créer un Secret "my-cert-tls" contenant tls.crt et tls.key.
# C'est exactement ce que les tests unitaires vérifient.

Code kubectl — vérifier l’état réel après réconciliation

# Appliquer l'état désiré
kubectl apply -f certificate.yaml
# certificate.cert.example.com/my-cert created

# Observer le Secret créé par le reconciler (état réel convergé)
kubectl get secret my-cert-tls -o jsonpath='{.data}' | jq 'keys'
# ["tls.crt", "tls.key"]

# Vérifier la condition Ready sur la CR
kubectl get certificate my-cert \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
# True

Piège courant : « Mocker l’intégralité de l’API Kubernetes dans les tests unitaires suffit à valider un Operator » est une approximation dangereuse. Les mocks ne reproduisent pas les conflits de resourceVersion lors des mises à jour concurrentes, le comportement exact du server-side apply (chapitre day 27-28), ni les erreurs 409 Conflict qui se produisent en conditions réelles. Les tests unitaires valident la logique de diff ; les tests d’intégration valident l’interaction avec l’API server.


Tests d’intégration : envtest et kind

L’idée en une phrase

Les tests d’intégration vérifient que le reconciler fonctionne contre un vrai API server — envtest fournit un kube-apiserver et un etcd légers sans kubelet ni scheduler, kind lance un cluster Kubernetes complet dans Docker — chacun mesurant à un niveau de fidélité croissant l’écart entre le comportement testé et le comportement réel en production.

Analogie : Considérons un constructeur automobile qui teste un nouveau moteur. Le banc d’essai (envtest) fait tourner le moteur seul, hors du véhicule : on mesure le couple, la consommation, la réponse à l’accélération, sans la complexité de la transmission ni du châssis. Le circuit d’essai fermé (kind) place le moteur dans une voiture complète sur une piste contrôlée : on vérifie le comportement réel avec boîte de vitesses, freins et suspensions. L’un ne remplace pas l’autre — le banc d’essai est rapide et précis, le circuit est fidèle mais coûteux.

Points clés

  • envtest démarre un etcd et un kube-apiserver locaux, sans kubelet, scheduler ni kube-proxy. Les CRDs sont installées au démarrage via des fichiers YAML. Le reconciler peut créer, lire, mettre à jour et supprimer des objets réels dans l’API server, mais aucun pod ne sera schedulé. Idéal pour tester la logique CRUD, les status updates et les finalizers (chapitre day 25-26).
  • kind (Kubernetes IN Docker) crée un vrai cluster — potentiellement multi-nœuds — dans des conteneurs Docker. Kubelet, scheduler, kube-proxy, CoreDNS : tout y est. Les pods tournent réellement. Le démarrage prend 30 à 60 secondes, mais la fidélité à la production est élevée.
  • En .NET/KubeOps, les tests d’intégration exploitent WebApplicationFactory<T> du framework ASP.NET Core. Le package KubeOps.Testing fournit un IntegrationTestHost qui configure un kube-apiserver local (ou se connecte à un cluster existant), enregistre les contrôleurs et injecte un IKubernetesClient connecté.
  • Le pattern de test d’intégration pour un reconciler : créer la CR via le client → attendre la réconciliation (polling ou watch) → vérifier que les ressources enfants existent avec les bons champs → supprimer la CR → vérifier le nettoyage par le finalizer.
  • La pyramide de tests s’applique rigoureusement : beaucoup de tests unitaires rapides (logique de diff), quelques tests d’intégration envtest (CRUD réel), très peu de tests kind (scénarios nécessitant un vrai scheduler). Inverser cette pyramide ralentit le feedback du TDD et rend la suite fragile.

Exemple concret

Le CertificateController est testé avec envtest. Au démarrage du test, la CRD Certificate est installée dans le kube-apiserver local. Le test crée une CR Certificate pour le domaine api.example.com via le client, puis attend (avec un timeout de 10 secondes et un polling toutes les 500 ms) que le Secret my-cert-tls apparaisse dans le namespace. Le test vérifie que le Secret contient les clés tls.crt et tls.key, et que la condition Ready de la CR est passée à True. Ensuite, le test supprime la CR et vérifie que le finalizer (chapitre day 25-26) a nettoyé le Secret — aucun Secret orphelin ne subsiste. L’ensemble de ce scénario s’exécute en 2 à 4 secondes, contre 40 à 90 secondes dans kind.

envtest vs kind

Critèreenvtestkind
Composants démarrésetcd + kube-apiserverCluster complet (kubelet, scheduler, kube-proxy, CoreDNS)
Temps de démarrage1-3 secondes30-60 secondes
Pods réellement schedulésNonOui
Réseau entre podsNon disponibleFonctionnel
Volumes persistantsNon disponibleDisponibles
Fidélité à la productionMoyenne — suffisante pour le CRUD et les statusÉlevée — comportement quasi-identique
Cas d’usage principalLogique CRUD, finalizers, conditions, webhooksScheduling, réseau, volumes, cycle de vie complet

Code C# — test d’intégration avec KubeOps.Testing

// Test d'intégration : le reconciler tourne contre un vrai kube-apiserver.
// On vérifie le cycle complet : CR créée → Secret généré → CR supprimée → Secret nettoyé.
public class CertificateIntegrationTests : IClassFixture<OperatorFixture>
{
    private readonly IKubernetesClient _client;

    public CertificateIntegrationTests(OperatorFixture fixture)
    {
        // Le fixture démarre le host avec un kube-apiserver local (envtest)
        // et enregistre le CertificateController.
        _client = fixture.Services.GetRequiredService<IKubernetesClient>();
    }

    [Fact]
    public async Task Reconciler_Creates_Secret_And_Cleans_Up()
    {
        // 1. Déclarer l'état désiré — créer la CR
        var certificate = new V1Certificate
        {
            Metadata = new()
            {
                Name = "integration-cert",
                NamespaceProperty = "default"
            },
            Spec = new()
            {
                Domain = "test.example.com",
                RenewBeforeDays = 30
            }
        };

        await _client.Create(certificate);

        // 2. Attendre que la boucle observe l'écart et agisse
        var secret = await PollUntilAsync(
            () => _client.Get<V1Secret>("integration-cert-tls", "default"),
            timeout: TimeSpan.FromSeconds(10));

        // 3. Vérifier l'état réel — le Secret existe avec les bonnes clés
        Assert.NotNull(secret);
        Assert.True(secret.Data.ContainsKey("tls.crt"));
        Assert.True(secret.Data.ContainsKey("tls.key"));

        // 4. Vérifier la condition Ready sur la CR
        var updated = await _client.Get<V1Certificate>(
            "integration-cert", "default");
        var ready = updated!.Status.Conditions
            .First(c => c.Type == "Ready");
        Assert.Equal("True", ready.Status);

        // 5. Supprimer la CR — le finalizer doit nettoyer le Secret
        await _client.Delete(certificate);

        await PollUntilAsync(async () =>
        {
            var s = await _client.Get<V1Secret>(
                "integration-cert-tls", "default");
            return s is null ? "gone" : null;  // attendre la disparition
        }, timeout: TimeSpan.FromSeconds(10));
    }

    // Utilitaire de polling — attend qu'une condition soit satisfaite
    private static async Task<T?> PollUntilAsync<T>(
        Func<Task<T?>> check,
        TimeSpan timeout,
        int intervalMs = 500) where T : class
    {
        var deadline = DateTime.UtcNow + timeout;
        while (DateTime.UtcNow < deadline)
        {
            var result = await check();
            if (result is not null) return result;
            await Task.Delay(intervalMs);
        }
        throw new TimeoutException("La condition n'a pas été satisfaite dans le délai imparti.");
    }
}

Code bash — créer un cluster kind et exécuter les tests end-to-end

# Créer un cluster kind pour les tests end-to-end
cat <<EOF | kind create cluster --name cert-operator-test --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
EOF
# Creating cluster "cert-operator-test" ...
#  ✓ Ensuring node image
#  ✓ Preparing nodes
#  ✓ Writing configuration
#  ✓ Starting control-plane
#  ✓ Joining worker nodes
# Set kubectl context to "kind-cert-operator-test"

# Installer les CRDs dans le cluster
kubectl apply -f config/crds/
# customresourcedefinition.apiextensions.k8s.io/certificates.cert.example.com created

# Construire et charger l'image de l'Operator dans kind
docker build -t cert-operator:test .
kind load docker-image cert-operator:test --name cert-operator-test

# Déployer l'Operator
kubectl apply -f config/deploy/
# deployment.apps/cert-operator created

# Exécuter les tests end-to-end (ici via dotnet test filtré sur la catégorie)
dotnet test --filter "Category=E2E" \
  --environment KUBECONFIG=$HOME/.kube/config
# Passed!  - Failed: 0, Passed: 4, Skipped: 0

# Nettoyer le cluster après les tests
kind delete cluster --name cert-operator-test
# Deleting cluster "cert-operator-test" ...

Code YAML — configuration du fixture envtest

# test-setup/certificate-crd.yaml — CRD installée au démarrage d'envtest
# Le fixture charge ce fichier pour que l'API server reconnaisse la ressource Certificate.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: certificates.cert.example.com
spec:
  group: cert.example.com
  names:
    kind: Certificate
    listKind: CertificateList
    plural: certificates
    singular: certificate
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      subresources:
        status: {}            # active le status subresource (chapitre day 21-22)
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                domain:
                  type: string
                renewBeforeDays:
                  type: integer
            status:
              type: object
              properties:
                conditions:
                  type: array
                  items:
                    type: object
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                      reason:
                        type: string
                      message:
                        type: string
                      lastTransitionTime:
                        type: string
                        format: date-time

Piège courant : « Exécuter tous les tests dans kind pour maximiser le réalisme » est une erreur classique d’ingénierie. Kind prend 30 à 60 secondes à démarrer et chaque test y est 10 à 100 fois plus lent qu’avec envtest. La pyramide de tests s’applique : la logique de diff se teste unitairement (millisecondes), la logique CRUD dans envtest (secondes), et kind est réservé aux scénarios qui nécessitent un vrai scheduler — scheduling, volumes, réseau entre pods. Inverser cette pyramide allonge le feedback du TDD au point de le rendre impraticable.


Quiz — validation des acquis
Expert 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Relire la fiche memo ci-dessus en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.