Ein moderner Blick auf Test Data Fixtures in Tests
Testcode ist Produktionscode. Behandle ihn auch so – mit lesbaren Testdaten.
Ich schreibe sehr gerne Tests und bin der Meinung: Ein Test sollte seine Testdaten mitbringen. Dadurch ist er robuster, wartungsfreundlicher und leichter nachvollziehbar.
The best tests don’t just pass — they teach.
Das Problem
Der folgende Test erzeugt das System Under Test (SUT) mit allen Eigenschaften, die für ein valides Objekt benötigt werden. Einige Eigenschaften sind wichtig um nachvollziehen zu können, was getestet bzw. sichergestellt werden soll. Die meisten Eigenschaften lenken lediglich ab.
Na, schon mal sowas geschrieben? Probiere es selber aus! Wie schnell kannst du verstehen, was dieser Test sicherstellen soll? ⏲️
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class VanTests
{
[Fact(DisplayName = "Only loaded payloads count toward the gross weight")]
public void Test001()
{
var sut = new Van(
id: Guid.NewGuid(),
name: "My Van",
curbWeight: Mass.FromKilograms(2000),
grossVehicleWeightRating: Mass.FromKilograms(2000),
payloads: new List<Payload>()
{
new Payload(
id: Guid.NewGuid(),
name: "My loaded Payload",
quantity: 1,
weight: Mass.FromKilograms(2),
isLoaded: true),
new Payload(
id: Guid.NewGuid(),
name: "My unloaded Payload",
quantity: 1,
weight: Mass.FromKilograms(4),
isLoaded: false)
});
sut.GrossWeight.Should().Be(Mass.FromKilograms(2));
}
}
Das nächste Codebeispiel zeigt den gleichen Test auf das Wesentliche reduziert…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class VanTests
{
[Fact(DisplayName = "Only loaded payloads count toward the gross weight")]
public void Test001()
{
var sut = new Van(
payloads: new List<Payload>()
{
new Payload(
weight: Mass.FromKilograms(2),
isLoaded: true),
new Payload(
weight: Mass.FromKilograms(4),
isLoaded: false)
});
sut.GrossWeight.Should().Be(Mass.FromKilograms(2));
}
}
…deutlich nachvollziehbarer, oder? Kompiliert allerdings nicht. 😕
Jetzt könnte man einwenden: »Meine Klassen haben nur Auto-Properties (get; set;
). Ich setze einfach nur die Eigenschaften, die im jeweiligen Test relevant sind.« Das ist - trotz der bekannten Nachteile von Auto-Properties - grundsätzlich korrekt. Und auch wenn ich kein Fan prozeduraler Programmierung bin, bin ich überzeugt: Test Data Fixtures
können auch in einem prozedural geprägten Stil die Arbeit mit Tests erheblich erleichtern.
Die Lösung
Der Begriff Fixture lässt sich nicht wirklich sinnvoll ins deutsche übersetzen. Er ist allerdings in vielen Test Frameworks etabliert und beschreibt Klassen oder Code, welcher die Testumgebung oder Teile derer vorbereitet, die für die Durchführung von Tests erforderlich sind.
Um eine semantische Nähe zu anderen Fixtures herzustellen und die Verwechselungsgefahr mit anderen Buildern zu reduzieren, nenne ich diese Test Data Builder [Pryce2007] stattdessen Test Data Fixtures:
Hier ist noch einmal gleiche Test, diesmal mit einem Test Data Fixture
umgesetzt:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class VanTests
{
[Fact(DisplayName = "Only loaded payloads count toward the gross weight")]
public void Test001()
{
var sut = new VanFixture()
.AddPayload(x =>
x.WithWeight(Mass.FromKilograms(2)))
.AddUnloadedPayload(x =>
x.WithWeight(Mass.FromKilograms(4)))
.Build();
sut.GrossWeight.Should().Be(Mass.FromKilograms(2));
}
}
Neat, oder? 😉 Wie baut man sowas? Ich hau hier mal beide Fixtures rein und erkläre in den Kommentaren:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public record VanFixture
{
// Felder mit "sensitive defaults" um den Zustand des Fixtures zwischenzuspeichern.
private Guid _id = Guid.NewGuid();
private string _name = "My Van";
private Mass _curbWeight = Mass.FromKilograms(2800);
private Mass _grossVehicleWeightRating = Mass.FromKilograms(3500);
// Eine leere Liste für untergeordnete Entitäten (auch als Fixtures ausgeprägt).
private List<PayloadFixture> _payloads = [];
// In der Build-Methode wird der Konstruktor der Klasse,
// die das Fixture baut, aufgerufen.
public Van Build()
{
return new Van(
_id,
_name,
_curbWeight,
_grossVehicleWeightRating,
_payloads.Select(x => x.Build()).ToList());
}
// Für eigene Eigenschaften wird "With" verwendet.
public VanFixture WithName(string value)
{
// Hier verwenden wir ein Sprachfeature,
// das seit C# 9 (.NET 5) zur Verfügung steht.
return this with { _name = value };
}
// Jetzt wirds fancy! Mit Hilfe der Func können wir über unsere
// Fluent API, die der Fixtures für unserer untergeordneten
// Entitäten aufrufen.
public VanFixture AddPayload(Func<PayloadFixture, PayloadFixture> func)
{
return this with
{
_payloads = _payloads.Append(
func(new PayloadFixture())).ToList()
};
}
public VanFixture AddUnloadedPayload(Func<PayloadFixture, PayloadFixture> func)
{
return this with
{
_payloads = _payloads.Append(
func(new PayloadFixture().WithIsLoaded(false))).ToList()
};
}
}
Der Vollständigkeit halber hier die PayloadFixture
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public record PayloadFixture
{
private Guid _id = Guid.NewGuid();
private string _name = "My Payload";
private uint _quantity = 1;
private Mass _weight = Mass.FromKilograms(1);
private bool _isLoaded = true;
public Payload Build()
{
return new Payload(_id, _name, _quantity, _weight, _isLoaded);
}
public PayloadFixture WithIsLoaded(bool value)
{
return this with { _isLoaded = value };
}
public PayloadFixture WithName(string name)
{
return this with { _name = name };
}
}
Ich baue ständig solche Fixtures. Wenn man von Anfang an damit startet, wachsen sie gemeinsam mit dem Produktivcode. Aber gerade in brownfield Projekten, mit großen, komplizierten Klassen, die viele Eigenschaften haben, empfinde ich sie als lästige, wenngleich notwendige Arbeit.
BONUS: Fluentify
Das hat auch Paul Martins (https://github.com/MooVC) gedacht und ein vielversprechendes Projekt ins Leben gerufen, das ich bereits produktiv verwende und als äußerst empfehlenswert empfinde: https://github.com/MooVC/Fluentify
Fluentify ist zwar nicht direkt für Test Data Builder
gedacht, lässt sich aber hervorragend dafür verwenden indem man das https://github.com/MooVC/Fluentify?tab=readme-ov-file#building-a-service Beispiel verwendet:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Fluentify]
public partial record PayloadFixture
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "My Payload";
public uint Quantity { get; set; } = 1;
public Mass Weight { get; set; } = Mass.FromKilograms(1);
public bool IsLoaded { get; set; } = true;
public Payload Build()
{
return new Payload(Id, Name, Quantity, Weight, IsLoaded);
}
}
Lediglich den Teil mit Func<Fixture, Fixture>
müssen wir noch selbst schreiben. 🤘
Links
- https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/with-expression
- https://github.com/MooVC/Fluentify
PS Beim Schreiben dieses Artikels habe ich versucht herauszufinden, woher die Idee ursprünglich stammt.
Test Data Builders: an alternative to the Object Mother pattern [Nat Pryce, 2007] (http://www.natpryce.com/articles/000714.html)