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 deIKubernetesClientqui simule l’état réel du cluster. Le test configure le mock (« le Deployment existe avec 3 replicas disponibles »), appelleReconcileAsync, 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
ReconcileAsyncdeux 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
| Niveau | Outil | Vitesse | Ce qu’il vérifie | Volume |
|---|---|---|---|---|
| Unitaire | xUnit + mocks | Millisecondes | Logique de diff, idempotence, cas limites | Beaucoup (~80 %) |
| Intégration | envtest | Secondes | CRUD réel, status updates, finalizers | Quelques-uns (~15 %) |
| End-to-end | kind | 30-60 s de setup | Scheduling, réseau, volumes, cycle de vie complet | Trè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
resourceVersionlors des mises à jour concurrentes, le comportement exact du server-side apply (chapitre day 27-28), ni les erreurs409 Conflictqui 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
etcdet unkube-apiserverlocaux, 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 unIntegrationTestHostqui configure unkube-apiserverlocal (ou se connecte à un cluster existant), enregistre les contrôleurs et injecte unIKubernetesClientconnecté. - 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ère | envtest | kind |
|---|---|---|
| Composants démarrés | etcd + kube-apiserver | Cluster complet (kubelet, scheduler, kube-proxy, CoreDNS) |
| Temps de démarrage | 1-3 secondes | 30-60 secondes |
| Pods réellement schedulés | Non | Oui |
| Réseau entre pods | Non disponible | Fonctionnel |
| Volumes persistants | Non disponible | Disponibles |
| Fidélité à la production | Moyenne — suffisante pour le CRUD et les status | Élevée — comportement quasi-identique |
| Cas d’usage principal | Logique CRUD, finalizers, conditions, webhooks | Scheduling, 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.