In mijn carrière als softwareontwikkelaar heb ik vaak liggen worstelen met de vraag wat een unittest is. Vers uit school gaf ik het tekstboekantwoord met de regels waar een unittest aan moest voldoen. Later realiseerde ik hoe vaag het tekstboekantwoord voor mezelf en andere kan zijn en stapte ik over op een concreter antwoord. Ik antwoordde stellig dat een class de harde grens was. Tussendoor heb ik ook nog wel eens uitstapjes gemaakt naar de arrogante ontwikkelaar die geen testen maakt voor zijn projecten. Ik werd steeds meer met de neus op de feiten gedrukt. Ik zag dat de kwaliteit van het product met testen en zonder testen niet veel verschilde. Beide hadden net zoveel bugs en door het maken van testen duurde het langer om iets op te leveren. Het werd me steeds duidelijker dat de kwaliteitswaarborging die ik nastreefde totaal geen kwaliteit was. Ik werd steeds onzekerder van mijn antwoord en uiteindelijk moest ik erkennen dat ik alleen maar het antwoord geen idee kon geven. Tenminste totdat ik deze blog ging schrijven.
#Inhoud
In het vorige deel van de blog heb ik mij verdiept in wat naar mijn mening niet
de definitie van een unit in een unittest is. Het heeft weinig waarde als er niet
een goed alternatief tegenover staat, geautomatiseerde testen moet wel gemaakt worden.
Unittesten blijven nog altijd de basis van de testpiramide. Het doel van deze blog
is te verdiepen in wat naar mijn mening wel de unit in een unittest is.
Om tot een goed antwoord te komen van wat een unit is, zullen we eerst moeten
erkennen dat het concept van geautomatiseerde testen niet direct aan enige
programmeertaal gelinkt kan worden. Het is wel algemeen bekend dat bij iedere
software unittesten gemaakt moeten worden en software kan in iedere willekeurige
programmeertaal (C#, Java, Swift, C++, Ruby, JavaScript, TypeScript, Algol,
Prolog, etc.) geschreven worden. Het concept van een unittest en de tekstboek
regels voor een goede unittest veranderen niet per programmeertaal. De discipline
van het schrijven van geautomatiseerde testen overstijgt het programmeren van de
code. Waar moeten we dan naar kijken om erachter te komen wat een unit is?
De Clean Architecture
Er is een discipline dat ook voor een groot deel onafhankelijk is van de
programmeertaal. En dat is softwaredesign en softwarearchitectuur. In de vorige
blog merkte ik op dat de termen module en componenten vooral in softwaredesign/
-architectuur gebruikt worden. Er zullen zeer waarschijnlijk talen zijn die de
termen gebruiken voor keywords, maar unittesten is niet exclusief voor die talen.
In de geschiedenis van softwareontwikkeling zijn er al een aantal concepten
bedacht om goed softwaredesign uit te drukken, zoals:
- Hexagonal Architecture ontwikkeld door Alistair Cockburn,
- Data, Context and Interaction van James Coplien en Trygve Reenskaug,
- Onion Architecture van Jeffrey Palermo,
- Clean Architecture van Robert C. Martin.
Deze laatste zal ik hier ook gebruiken om aan te geven wat een unit is.

Bron: The Clean Architecture van Robert C. Martin
Hierboven is het Clean Architecture concept afgebeeld. Ik zal in vogelvlucht
dit concept uitleggen. Voor een meer gedetailleerde uitleg raad ik je aan om de
blogs van Robert C. Martin of zijn boek The Clean Architecture te lezen.
Zoals afgebeeld bestaat de architectuur uit vier ringen/lagen. In de Clean
Architecture hebben ringen alleen afhankelijkheden van de ring naar binnen. De
Dependency Rule zegt dat in geen geval iets in een ring een afhankelijkheid
heeft op iets in een ring naar buiten.
De binnenste ring, Entities, zijn de Enterprise Business Rules. Dit is de
logica die het geld oplevert. Deze regels worden door het bedrijf uitgevoerd
ongeacht of het in de software opgenomen wordt of niet. De ring daaromheen, Use
Cases, zijn de Application Business Rules. Dit is het automatiseren van de
executie van de Enterprise Business Rules, dat het voor een bedrijf gemakkelijker
en goedkoper maakt. Dit is de code dat de flow van data controleert, de Entities
aan het werk zet en de software zijn waarde geeft. Vervolgens heb je de ring van
Interface Adapters. Simpel gezegd is dit het onderdeel dat data van en naar
de Use Cases transformeert. Als er een informatie flow is van buitenaf naar de
business rules dan is deze laag verantwoordelijk om die data om te zetten in
data objecten die de business rules kunnen gebruiken. En zo ook omgekeerd wanneer
bijvoorbeeld data gepersisteerd moet worden in de database. De buitenste ring
zijn de Frameworks & Drivers. Dit zijn de plugins voor je module en in
unittesten zullen deze nooit betrokken zijn. Let wel op dat deze ring ook de term
External Interfaces bevat. Robert C. Martin gaat hier niet echt dieper op in,
maar naar mijn mening worden hier de software modules mee bedoeld die ook weer
hun eigen business rules hebben.
Geautomatiseerde testen zijn onderdeel van je systeem.
Er is nog een andere reden waarom ik naar softwarearchitectuur refereer. Want
voor onze productiecode zijn we als ontwikkelaars heel voorzichtig met het
schrijven en indelen van die code. We zorgen ervoor dat de code van hoge kwaliteit is
en dat we alle good practices van softwareontwikkeling naleven, met name de
SOLID principes. We gebruiken bijvoorbeeld Autofac om aan het principe van
Dependency Inversion te voldoen. We maken een interface voor iedere class om zo
een “niet sterk gekoppeld” systeem te realiseren. En we zijn zorgvuldig met welke
functionaliteiten we aan een class toekennen zodat we niet de Single
Responsibility principe breken.
Waarom zijn we dan niet zo zorgvuldig met onze geautomatiseerde testen? Ik heb vaak
gezien dat geautomatiseerde testen als een extra toegevoegd worden, zonder enige
gedachte over wat er gemaakt wordt. Sterk gekoppelde classes; lange setups om mocks
het juiste gedrag te geven. Als er ook maar enige tijdsdruk is om software te leveren
dan zijn de geautomatiseerde testen ook het eerste die niet meer gemaakt worden.
Automatische testen worden gemaakt onder het mom dat good practice is. Geautomatiseerde
testen zijn vaak maar een afterthought. Ik zelf ben hier ook schuldig aan.
In zijn boeken en lezingen stelt Robert C. Martin dat geautomatiseerde testen ook
een onderdeel zijn van je gehele systeem, ondanks dat het de schijn heeft dat
deze testen niet geleverd worden aan de klant. Niets is minder waar. Zelfs voor
de geautomatiseerde testen is er een klant die ze belangrijk vindt. De klanten
zijn de software ontwikkelaars zelf en het bedrijf dat de software
aan hun klanten verkoopt. Iedere test is een applicatie om een specifiek
onderdeel van het systeem te testen. Een test waarborgt dat de resultaten zijn
zoals de schrijver van de test verwacht dat ze moeten zijn. Dus een test is om te
zien of de waarde die gemaakt wordt ook daadwerkelijk aanwezig is en aanwezig
blijft. Indirect zal de klant ook de stabiliteit merken.
Geautomatiseerde testen zijn dus hun eigen module die gebruik maakt van de
module onder test. Deze module moet je dus zien als de module op applicatie
niveau. Je unittesten bepalen de compositie van het gehele systeem dat in werking
gesteld wordt. Waardoor het mogelijk is om met mocks te werken en dingen zoals I/O,
netwerk en frameworks weg te werken, en alles volledig in-memory te laten
draaien. De unittesten werken ook met net genoeg data om aan te tonen dat de
business rules toegepast worden.
Een unittest waarborgt het geld en de waarde.
Uit het verhaal hierboven kan je waarschijnlijk afleiden dat wat ik zie als een
unit de Business Rules zijn. De uitvoering en de automatisering van deze
business rules zijn de zaken die geld op leveren voor het bedrijf. En als
ontwikkelaars worden wij niet betaald om zo maar code te schrijven. Wij worden
betaald om waarde toe te voegen aan de software, het product en het bedrijf. De
business rules zijn ook het concept dat met gedrag bedoeld wordt, want deze
regels worden uitgevoerd of jouw software er nu bij betrokken is of niet. Met de
business rules heb ik het niet alleen over het scenario dat het hoofdpad vormt
voor het succesvol uitvoeren van de regels. Maar ook de edge-case scenario’s en
mogelijke error scenario’s die door de automatisering correct afgehandeld moet
worden. Er geen fouten in het systeem ontstaan waardoor je software niet goed kan
functioneren voor de klant.
Een andere grens voor een unit kan ook bij de grenzen van iedere ring liggen en
dat je dus unittesten maakt waarbij de onderliggende ring gemockt wordt. Hier heb
ik ook nog over nagedacht en ik ben tot conclusie gekomen dat dit ook niet de
correcte manier is van unittesten. Bij iedere mock dat je aan je testen toevoegt,
maak je impliciet ook een aanname van hoe de werkelijke class die je mockt zou
moeten werken. Als die aanname niet klopt dan zullen je unittesten dat niet
afvangen. Bij use cases en entities is het niet te vermijden dat het gedrag
in beide ringen verbonden zijn met elkaar. Het is de interactie tussen beide
die ervoor zorgt dat de module zijn waarde krijgt. Er geld met het gebruik van
de module verdient kan worden. Het is dus belangrijk om snel feedback te kunnen
krijgen of een aanpassing een business rule kapot gemaakt heeft.
Je zal nu wel denken dat dit leuk en aardig is. Echter krijgen we niet een
zo groot mogelijke test coverage omdat we twee ringen in het model vergeten. Het
is van belang om te beseffen wat er in de buitenste ring. Deze ring bevat namelijk
de plugins van je module waaronder software van derde partijen vallen. Voorbeelden
hiervan zijn: databases en .Net Framework, maar ook de andere modules van je
software die op hun beurt huneigen business rules hebben en dus zelf van unittesten zijn voorzien.
Dan blijft er nog de ring van de Interface Adapters over. Zoals eerder vermeld is het
aan de code in deze laag de verantwoordelijkheid om data te transformeren zodat
de ontvangende module de gegevens kunnen gebruiken. Het zou niet fout zijn om
hier ook unittesten voor te maken. Maar dit is ook weer afhankelijk van de
complexiteit van je interface adapters. En als deze ring de overgang is met I/O
dan zijn deze classes niet met unittesten te testen. Met integratietesten test je
juist de interactie met I/O en de interactie tussen modules en dat houdt dus in
de transformatie van data die de Interface Adapters uitvoeren. De integratie
testen zouden in ieder geval niet nog een keer de business rules moeten testen om
te zien of alle aannames kloppen. Als je dit doet dan wordt je test piramide een
test huisje, want je laag aan integratietesten wordt net zo groot als of zelfs
groter dan je laag van unittesten.
Dat je nu business rules gaat testen in plaats van classes wil niet zeggen dat
je nooit meer de code van je unittesten moet aanpassen omdat er iets in je
implementatie verandert. Naamswijzigingen in je classes, interfaces en functies
kunnen invloed hebben op je unittesten. Maar ook veranderingen in je function
signatures hebben effect op je unittesten. In al van deze gevallen betekent
het dus dat je breaking changes hebt in je publieke interfaces waardoor andere
modules die gebruik maken van jouw modules ook zullen breken. En dit is iets wat
je dus aan je collega’s moet communiceren. En als laatste is nog het geval
van herstructureren van je code. De geautomatiseerde testen zijn zelf een
applicatie en uiteindelijk is de applicatie verantwoordelijk voor de compositie
van je classes. Wanneer je stukken code verplaatst naar nieuwe classes dan moet
de compositie van geautomatiseerde testen ook aangepast worden en de nieuwe
classes toevoegen.
Een concreet voorbeeld
Laten we deze theorie nu eens in actie zien met een voorbeeld. In dit voorbeeld
maak ik twee sets van unittesten voor een module. De eerste set A zal
unittesten maken waarbij een class de harde grens van een unit is. De tweede set
B zal unittesten maken voor de business rules van de module. De module zelf
zorgt ervoor dat data die binnen komt getransformeerd wordt door middel van
wiskundige formules naar een grafiek die leesbaar is voor eindgebruikers. Voor
het renderen van de grafiek wordt gebruik gemaakt van een module van een derde
partij. Als eerste wordt er een service gemaakt die data over tijd uit zet
volgens de formule N: n = a^3 + b^2 + c.
De eerste business rule
ProcessServiceOne
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
| public class ProcessServiceOne
{
private readonly IGraphRenderer _renderer;
public ProcessServiceOne(IGraphRenderer renderer)
{
_renderer = renderer;
}
public Bitmap ProcessToGraph(IEnumerable<TimedSample> samples)
{
var datapoints = samples.Select(sample => new TimeDataPoint
{
X = sample.TimeStamp,
Y = FormulaN(sample.A, sample.B, sample.C)
})
.ToList();
_renderer.SetTitle("Data over Time");
_renderer.AddLine(datapoints, Color.Blue);
return _renderer.GetImage();
}
private double FormulaN(double a, double b, double c)
{
return (a * a * a) + (b * b) + c;
}
}
public class TimedSample : IEquatable<TimedSample>
{
public double A { get; set; }
public double B { get; set; }
public double C { get; set; }
public TimeSpan TimeStamp { get; set; }
public bool Equals([AllowNull] TimedSample other)
{
if(other == null)
{
return false;
}
return this.A == other.A
&& this.B == other.B
&& this.C == other.C
&& this.TimeStamp == other.TimeStamp;
}
}
|
Scenario A unittesten
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
| [TestFixture]
public class ProcessServiceOneTests
{
private Mock<IGraphRenderer> _rendererMock;
private ProcessServiceOne _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
_sut = new ProcessServiceOne(_rendererMock.Object);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void ProcessToGraph_Should_SetTheCorrectTitle()
{
// Arrange
var expected = "Data over Time";
// Act
var _ = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_AddALineWithDataCalculatedByFormulaN()
{
// Arrange
var input = new List<TimedSample>
{
new TimedSample
{
A = 3d,
B = 7d,
C = -1d,
TimeStamp = TimeSpan.FromMilliseconds(20)
},
new TimedSample
{
A = -2d,
B = 10d,
C = 5d,
TimeStamp = TimeSpan.FromMilliseconds(55)
}
};
var expected = new List<TimeDataPoint>
{
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(20),
Y = 75d
},
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(55),
Y = 97d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<TimeDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Blue)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_ReturnBitmapFromRenderer()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
|
Scenario B unittesten
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
| [TestFixture]
public class CreatingADataAOverTimeGraph
{
private Mock<IGraphRenderer> _rendererMock;
private ProcessServiceOne _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
_sut = new ProcessServiceOne(_rendererMock.Object);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void Should_HaveTheTitle_DataOverTime()
{
// Arrange
var expected = "Data over Time";
// Act
var _ = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void Should_RenderABlueLineWithDataCalculatedByFormulaN()
{
// Arrange
var input = new List<TimedSample>
{
new TimedSample
{
A = 3d,
B = 7d,
C = -1d,
TimeStamp = TimeSpan.FromMilliseconds(20)
},
new TimedSample
{
A = -2d,
B = 10d,
C = 5d,
TimeStamp = TimeSpan.FromMilliseconds(55)
}
};
var expected = new List<TimeDataPoint>
{
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(20),
Y = 75d
},
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(55),
Y = 97d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<TimeDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Blue)),
Times.Once);
}
[Test]
public void Should_ResultInABitmapThatCanBeUsedToDisplayTheGraph()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
|
Beide scenario’s van unittesten zijn bijna identiek. Dit komt doordat de gehele
business rule in een enkele class gerealiseerd is. Als we kijken naar de output
van de Test Explorer zien we ook geen noemenswaardige verschillen. Beide
scenario’s doen er ongeveer 70 milliseconden over hun tests.

De output van de Test Explorer. De testen ATestRunnerInit zijn toegevoegd om
de overhead van het opstarten van de testrunner en de initialisatie van de
testclass zichtbaar te maken.
Het werk van het herstructureren
Na deze service wordt een tweede service toegevoegd die data over positie
weergeeft en ook gebruik maakt van formule N. De code van de eerste service
wordt nu geherstructureerd zodat de formule N berekening hergebruikt kan worden.
Dit resulteert in de volgende services:
ProcessServiceOne
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 ProcessServiceOne
{
private readonly IGraphRenderer _renderer;
private readonly ICalculator _calculator;
public ProcessServiceOne(IGraphRenderer renderer, ICalculator calculator)
{
_renderer = renderer;
_calculator = calculator;
}
public Bitmap ProcessToGraph(IEnumerable<TimedSample> samples)
{
var datapoints = samples.Select(sample => new TimeDataPoint
{
X = sample.TimeStamp,
Y = _calculator.FormulaN(sample.A, sample.B, sample.C)
})
.ToList();
_renderer.SetTitle("Data over Time");
_renderer.AddLine(datapoints, Color.Blue);
return _renderer.GetImage();
}
}
public class TimedSample : IEquatable<TimedSample> {...}
|
ProcessServiceTwo
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
| public class ProcessServiceTwo
{
private readonly IGraphRenderer _renderer;
private readonly ICalculator _calculator;
public ProcessServiceTwo(IGraphRenderer renderer, ICalculator calculator)
{
_renderer = renderer;
_calculator = calculator;
}
public Bitmap ProcessToGraph(IEnumerable<PositionSample> samples)
{
var datapoints = samples.Select(sample => new ValueDataPoint
{
X = sample.Position,
Y = _calculator.FormulaN(sample.A, sample.B, sample.C)
})
.ToList();
_renderer.SetTitle("Data over Position");
_renderer.AddLine(datapoints, Color.Green);
return _renderer.GetImage();
}
}
public class PositionSample : IEquatable<PositionSample>
{
public double A { get; set; }
public double B { get; set; }
public double C { get; set; }
public double Position { get; set; }
public bool Equals([AllowNull] PositionSample other)
{
if (other == null)
{
return false;
}
return this.A == other.A
&& this.B == other.B
&& this.C == other.C
&& this.Position == other.Position;
}
}
|
Calculator
1
2
3
4
5
6
7
8
9
10
11
12
| public class Calculator : ICalculator
{
public double FormulaN(double a, double b, double c)
{
return (a * a * a) + (b * b) + c;
}
}
public interface ICalculator
{
double FormulaN(double a, double b, double c);
}
|
Scenario A unittesten
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
| [TestFixture]
public class ProcessServiceOneTests
{
private Mock<IGraphRenderer> _rendererMock;
private Mock<ICalculator> _calculatorMock;
private ProcessServiceOne _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
_calculatorMock = new Mock<ICalculator>();
_sut = new ProcessServiceOne(_rendererMock.Object, _calculatorMock.Object);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void ProcessToGraph_Should_SetTheCorrectTitle()
{
// Arrange
var expected = "Data over Time";
// Act
var _ = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheInputSamplesToTheCalculator()
{
// Arrange
var input = new List<TimedSample>
{
new TimedSample
{
A = 3d,
B = 7d,
C = -1d
},
new TimedSample
{
A = -2d,
B = 10d,
C = 5d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == 3d),
It.Is<double>(actual => actual == 7d),
It.Is<double>(actual => actual == -1d)),
Times.Once);
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == -2d),
It.Is<double>(actual => actual == 10d),
It.Is<double>(actual => actual == 5d)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheResultFromTheCalculatorWithTheTimesToTheRenderer()
{
// Arrange
var expectedFirst = 75d;
var expectedSecond = 97d;
var input = new List<TimedSample>
{
new TimedSample
{
TimeStamp = TimeSpan.FromMilliseconds(20)
},
new TimedSample
{
TimeStamp = TimeSpan.FromMilliseconds(55)
}
};
var expected = new List<TimeDataPoint>
{
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(20),
Y = expectedFirst
},
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(55),
Y = expectedSecond
}
};
_calculatorMock.SetupSequence(mock => mock.FormulaN(
It.IsAny<double>(),
It.IsAny<double>(),
It.IsAny<double>()))
.Returns(expectedFirst)
.Returns(expectedSecond);
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<TimeDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Blue)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_ReturnBitmapFromRenderer()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
[TestFixture]
public class ProcessServiceTwoTests
{
private Mock<IGraphRenderer> _rendererMock;
private Mock<ICalculator> _calculatorMock;
private ProcessServiceTwo _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
_calculatorMock = new Mock<ICalculator>();
_sut = new ProcessServiceTwo(_rendererMock.Object, _calculatorMock.Object);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void ProcessToGraph_Should_SetTheCorrectTitle()
{
// Arrange
var expected = "Data over Position";
// Act
var _ = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheInputSamplesToTheCalculator()
{
// Arrange
var input = new List<PositionSample>
{
new PositionSample
{
A = 3d,
B = 7d,
C = -1d
},
new PositionSample
{
A = -2d,
B = 10d,
C = 5d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == 3d),
It.Is<double>(actual => actual == 7d),
It.Is<double>(actual => actual == -1d)),
Times.Once);
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == -2d),
It.Is<double>(actual => actual == 10d),
It.Is<double>(actual => actual == 5d)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheResultFromTheCalculatorWithTheTimesToTheRenderer()
{
// Arrange
var expectedFirst = 75d;
var expectedSecond = 97d;
var input = new List<PositionSample>
{
new PositionSample
{
Position = 20
},
new PositionSample
{
Position = 55
}
};
var expected = new List<ValueDataPoint>
{
new ValueDataPoint
{
X = 20,
Y = expectedFirst
},
new ValueDataPoint
{
X = 55,
Y = expectedSecond
}
};
_calculatorMock.SetupSequence(mock => mock.FormulaN(
It.IsAny<double>(),
It.IsAny<double>(),
It.IsAny<double>()))
.Returns(expectedFirst)
.Returns(expectedSecond);
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<ValueDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Green)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_ReturnBitmapFromRenderer()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
[TestFixture]
public class CalculatorTests
{
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[TestCase(3d, 7d, -1d, 75d)]
[TestCase(-2d, 10d, 5d, 97d)]
public void FormulaN_Should_ReturnTheExpectedValue(
double a,
double b,
double c,
double expected)
{
// Arrange
var sut = new Calculator();
// Act
var actual = sut.FormulaN(a, b, c);
// Assert
Assert.AreEqual(expected, actual);
}
}
|
Scenario B unittesten
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
| [TestFixture]
public class CreatingADataAOverTimeGraph
{
private Mock<IGraphRenderer> _rendererMock;
private ProcessServiceOne _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
var calculator = new Calculator();
_sut = new ProcessServiceOne(_rendererMock.Object, calculator);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void Should_HaveTheTitle_DataOverTime()
{
// Arrange
var expected = "Data over Time";
// Act
var _ = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void Should_RenderABlueLineWithDataCalculatedByFormulaN()
{
// Arrange
var input = new List<TimedSample>
{
new TimedSample
{
A = 3d,
B = 7d,
C = -1d,
TimeStamp = TimeSpan.FromMilliseconds(20)
},
new TimedSample
{
A = -2d,
B = 10d,
C = 5d,
TimeStamp = TimeSpan.FromMilliseconds(55)
}
};
var expected = new List<TimeDataPoint>
{
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(20),
Y = 75d
},
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(55),
Y = 97d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<TimeDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Blue)),
Times.Once);
}
[Test]
public void Should_ResultInABitmapThatCanBeUsedToDisplayTheGraph()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
[TestFixture]
public class CreatingADataOverPositionGraph
{
private Mock<IGraphRenderer> _rendererMock;
private ProcessServiceTwo _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
var calculator = new Calculator();
_sut = new ProcessServiceTwo(_rendererMock.Object, calculator);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void Should_HaveTheTitle_DataOverPosition()
{
// Arrange
var expected = "Data over Position";
// Act
var _ = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void Should_RenderAGreenLineWithDataCalculatedByFormulaN()
{
// Arrange
var input = new List<PositionSample>
{
new PositionSample
{
A = 3d,
B = 7d,
C = -1d,
Position = 20d
},
new PositionSample
{
A = -2d,
B = 10d,
C = 5d,
Position = 55d
}
};
var expected = new List<ValueDataPoint>
{
new ValueDataPoint
{
X = 20d,
Y = 75d
},
new ValueDataPoint
{
X = 55d,
Y = 97d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<ValueDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Green)),
Times.Once);
}
[Test]
public void Should_ResultInABitmapThatCanBeUsedToDisplayTheGraph()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
|
Door het herstructureren van formule N naar zijn eigen class zodat meerdere
services deze berekeningen ook kunnen gebruiken zie je dat dit een grote impact
heeft op de unittesten in scenario A. Een unittest is volledig verwijdert en
vervangen door andere testen die ander gedrag testen. Het aantal unittesten in
scenario A is ook verdrievoudigt ten opzichte van de verdubbeling in scenario B.
De impact op scenario B is eigenlijk heel klein. In de originele testen die
de eerste business rule testen, is alleen de setup aangepast waardoor
ProcessServiceOne zijn afhankelijkheid van de Calculator mee krijgt. Voor de rest
is het gedrag dat getest wordt hetzelfde gebleven en is alle werktijd gaan zitten
in het toevoegen van de unittesten voor de tweede business rule.
Als we kijken dat naar de output van de test explorer zien we dat de toename in
het aantal testen niet alleen voor een langer lijst zorgt maar dat de duur van
het draaien van de testen ook toeneemt. Scenario A heeft iets minder dan 200
milliseconden nodig, terwijl scenario B iet meer dan 100 milliseconden nodig
heeft.

De output van de Test Explorer. De verschillende tussen beide test-strategieën
wordt al duidelijker.
De introductie van bugs door een aanpassing
Je zou denken dat beide test-strategieën de ontwikkelaar het vertrouwen geeft om
code nieuwe functionaliteiten toe te voegen en andere verandering aan de software
te maken. In beide gevallen testen ze gedrag en zullen ze de ontwikkelaar waarschuwen
door een falende test wanneer een bug geïntroduceerd wordt. Tenminste dat zou je
denken, maar niets is minder waar.
In scenario B is de grens van een unit de business rule en de testen zijn er dan
ook op gericht om het gedrag van de business rule te waarborgen. In scenario A is
de grens van een unit een class en zijn de unittesten een tweede implementatie
van de betreffende class. Zij waarborgen het gedrag van een enkele class of zelfs
een functie binnen een enkele class. Maar mocht de class veranderen omdat de code
geherstructureerd wordt of een bug opgelost wordt, dan worden de unittesten net
zo snel vervangen. Dit is al een grote inbreuk op het vertrouwen. Naast deze
inbreuk zit er nog een gevaar, want het vangnet in scenario A zit vol met gaten
die bugs niet kunnen tegen gaan. Dit komt door de aannames die gemaakt worden op
het gedrag van de mocks.
Neem het volgende voorbeeld. De tweede business rule van hierboven is in
productie genomen en de klant is ermee aan het werk gegaan. De klant komt terug
met het verhaal dat de tweede business rule niet correct werkt en er een bug in
de software zit. Het feit is dat er origineel in de communicatie iets is
misgegaan. Voor de tweede business rule moet de data niet verwerkt worden met
formule n = a^3 + b^3 + c^3, maar n = |a|^3 + |b|^2 + |c|. De absolute
waardes voor de data moeten gebruikt worden in plaats van de originele waardes. Een
ontwikkelaar gaat met deze bug aan de slag. Hij past de Calculator class aan en
zorgt dat de testen voor de tweede business rule correct zijn. ProcessServiceOne
en ProcessServiceTwo blijven onveranderd.
Calculator
1
2
3
4
5
6
7
8
9
10
11
| public class Calculator : ICalculator
{
public double FormulaN(double a, double b, double c)
{
var absA = Math.Abs(a);
var absB = Math.Abs(b);
var absC = Math.Abs(c);
return (absA * absA * absA) + (absB * absB) + absC;
}
}
|
Scenario A unittesten
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
| [TestFixture]
public class ProcessServiceOneTests
{
private Mock<IGraphRenderer> _rendererMock;
private Mock<ICalculator> _calculatorMock;
private ProcessServiceOne _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
_calculatorMock = new Mock<ICalculator>();
_sut = new ProcessServiceOne(_rendererMock.Object, _calculatorMock.Object);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void ProcessToGraph_Should_SetTheCorrectTitle()
{
// Arrange
var expected = "Data over Time";
// Act
var _ = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheInputSamplesToTheCalculator()
{
// Arrange
var input = new List<TimedSample>
{
new TimedSample
{
A = 3d,
B = 7d,
C = -1d
},
new TimedSample
{
A = -2d,
B = 10d,
C = 5d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == 3d),
It.Is<double>(actual => actual == 7d),
It.Is<double>(actual => actual == -1d)),
Times.Once);
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == -2d),
It.Is<double>(actual => actual == 10d),
It.Is<double>(actual => actual == 5d)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheResultFromTheCalculatorWithTheTimesToTheRenderer()
{
// Arrange
var expectedFirst = 75d;
var expectedSecond = 97d;
var input = new List<TimedSample>
{
new TimedSample
{
TimeStamp = TimeSpan.FromMilliseconds(20)
},
new TimedSample
{
TimeStamp = TimeSpan.FromMilliseconds(55)
}
};
var expected = new List<TimeDataPoint>
{
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(20),
Y = expectedFirst
},
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(55),
Y = expectedSecond
}
};
_calculatorMock.SetupSequence(mock => mock.FormulaN(
It.IsAny<double>(),
It.IsAny<double>(),
It.IsAny<double>()))
.Returns(expectedFirst)
.Returns(expectedSecond);
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<TimeDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Blue)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_ReturnBitmapFromRenderer()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
[TestFixture]
public class ProcessServiceTwoTests
{
private Mock<IGraphRenderer> _rendererMock;
private Mock<ICalculator> _calculatorMock;
private ProcessServiceTwo _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
_calculatorMock = new Mock<ICalculator>();
_sut = new ProcessServiceTwo(_rendererMock.Object, _calculatorMock.Object);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void ProcessToGraph_Should_SetTheCorrectTitle()
{
// Arrange
var expected = "Data over Position";
// Act
var _ = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheInputSamplesToTheCalculator()
{
// Arrange
var input = new List<PositionSample>
{
new PositionSample
{
A = 3d,
B = 7d,
C = -1d
},
new PositionSample
{
A = -2d,
B = 10d,
C = 5d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == 3d),
It.Is<double>(actual => actual == 7d),
It.Is<double>(actual => actual == -1d)),
Times.Once);
_calculatorMock.Verify(mock => mock.FormulaN(
It.Is<double>(actual => actual == -2d),
It.Is<double>(actual => actual == 10d),
It.Is<double>(actual => actual == 5d)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_PassTheResultFromTheCalculatorWithTheTimesToTheRenderer()
{
// Arrange
var expectedFirst = 75d;
var expectedSecond = 97d;
var input = new List<PositionSample>
{
new PositionSample
{
Position = 20
},
new PositionSample
{
Position = 55
}
};
var expected = new List<ValueDataPoint>
{
new ValueDataPoint
{
X = 20,
Y = expectedFirst
},
new ValueDataPoint
{
X = 55,
Y = expectedSecond
}
};
_calculatorMock.SetupSequence(mock => mock.FormulaN(
It.IsAny<double>(),
It.IsAny<double>(),
It.IsAny<double>()))
.Returns(expectedFirst)
.Returns(expectedSecond);
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<ValueDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Green)),
Times.Once);
}
[Test]
public void ProcessToGraph_Should_ReturnBitmapFromRenderer()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
[TestFixture]
public class CalculatorTests
{
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[TestCase(3d, 7d, -1d, 77d)]
[TestCase(-2d, 10d, 5d, 113d)]
public void FormulaN_Should_ReturnTheExpectedValue(
double a,
double b,
double c,
double expected)
{
// Arrange
var sut = new Calculator();
// Act
var actual = sut.FormulaN(a, b, c);
// Assert
Assert.AreEqual(expected, actual);
}
}
|
Scenario B unittesten
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
| [TestFixture]
public class CreatingADataAOverTimeGraph
{
private Mock<IGraphRenderer> _rendererMock;
private ProcessServiceOne _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
var calculator = new Calculator();
_sut = new ProcessServiceOne(_rendererMock.Object, calculator);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void Should_HaveTheTitle_DataOverTime()
{
// Arrange
var expected = "Data over Time";
// Act
var _ = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void Should_RenderABlueLineWithDataCalculatedByFormulaN()
{
// Arrange
var input = new List<TimedSample>
{
new TimedSample
{
A = 3d,
B = 7d,
C = -1d,
TimeStamp = TimeSpan.FromMilliseconds(20)
},
new TimedSample
{
A = -2d,
B = 10d,
C = 5d,
TimeStamp = TimeSpan.FromMilliseconds(55)
}
};
var expected = new List<TimeDataPoint>
{
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(20),
Y = 75d
},
new TimeDataPoint
{
X = TimeSpan.FromMilliseconds(55),
Y = 97d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<TimeDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Blue)),
Times.Once);
}
[Test]
public void Should_ResultInABitmapThatCanBeUsedToDisplayTheGraph()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<TimedSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
[TestFixture]
public class CreatingADataOverPositionGraph
{
private Mock<IGraphRenderer> _rendererMock;
private ProcessServiceTwo _sut;
[SetUp]
public void SetUp()
{
_rendererMock = new Mock<IGraphRenderer>();
var calculator = new Calculator();
_sut = new ProcessServiceTwo(_rendererMock.Object, calculator);
}
[Test]
public void ATestRunnerInit()
{
Assert.IsTrue(true);
}
[Test]
public void Should_HaveTheTitle_DataOverPosition()
{
// Arrange
var expected = "Data over Position";
// Act
var _ = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
_rendererMock.Verify(mock => mock.SetTitle(
It.Is<string>(actual => actual == expected)),
Times.Once);
}
[Test]
public void Should_RenderAGreenLineWithDataCalculatedByFormulaN()
{
// Arrange
var input = new List<PositionSample>
{
new PositionSample
{
A = 3d,
B = 7d,
C = -1d,
Position = 20d
},
new PositionSample
{
A = -2d,
B = 10d,
C = 5d,
Position = 55d
}
};
var expected = new List<ValueDataPoint>
{
new ValueDataPoint
{
X = 20d,
Y = 77d
},
new ValueDataPoint
{
X = 55d,
Y = 113d
}
};
// Act
var _ = _sut.ProcessToGraph(input);
// Assert
_rendererMock.Verify(mock => mock.AddLine(
It.Is<List<ValueDataPoint>>(actual => expected.SequenceEqual(actual)),
It.Is<Color>(actual => actual == Color.Green)),
Times.Once);
}
[Test]
public void Should_ResultInABitmapThatCanBeUsedToDisplayTheGraph()
{
// Arrange
var expected = new Bitmap(10, 10, PixelFormat.Format32bppArgb);
_rendererMock.Setup(mock => mock.GetImage()).Returns(expected);
// Act
var actual = _sut.ProcessToGraph(new List<PositionSample>());
// Assert
Assert.AreEqual(expected, actual);
}
}
|
De aanpassingen lijken allemaal onschuldig. In scenario A worden de verwachte
resultaten van de unittesten voor de Calculator class aangepast. Alleen deze
class is gewijzigd. De classes ProcessServiceOne en ProcessServiceTwo zijn
ongewijzigd en de unittesten voor die classes waarborgen nog altijd dat de
resultaten uit de calculator doorgegeven wordt aan de graph renderer. In scenario
B wordt het verwachte berekende resultaat aangepast voor de
Data-Over-Position graph, omdat het gedrag van die business rule verandert is.

Als wij nu kijken naar de output van de Test Explorer zien we de grootste impact
van beide test strategieën. In scenario A slagen alle unittesten en kan de
ontwikkelaar de wijziging in productie brengen. In scenario B faalt er een test
van de eerste business rule. De ontwikkelaar moet nu geprikkeld zijn om de nieuw
geïntroduceerde bug op te lossen zonder de unittest aan te passen. Of zijn
wijzigingen terug te draaien en een andere oplossing te kiezen. Tevens kan de
ontwikkelaar contact met de klant zoeken om uit te vinden of de eerste business
rule die ook formule N gebruikt dezelfde bug bevat als de tweede business rule.
Het voorbeeld heeft nog maar één andere business rule, maar het hadden ook tien
verschillende business rules kunnen zijn die gebruik maken van formule N. Het
oplossen van één bug in scenario A levert negen potentieel andere bugs op. Het
probleem bij scenario A zit in de aannames van de mocks die gedaan
worden. Dit is ook een potentieel probleem bij scenario B, maar het verschil is
dat hier alleen externe code gemockt wordt, waar de ontwikkelaar geen invloed op
heeft. In scenario A worden ook de classes gemockt die de ontwikkelaar zelf
ontwikkelt. Iedere keer dat een je een deel van de software mockt, wordt er een aanname
gemaakt hoe de class werkt. Dat de class ook daadwerkelijk doet wat die moet doen.
En dat het gedrag van de productiecode van de mock past in de business rule die
ontwikkeld is. Het venijn is dat er geen unittesten zijn die de aannames testen.
Integratietesten zouden de aannames kunnen testen, maar dan zouden deze ook
vaker gedraaid moeten worden om de ontwikkelaar te prikkelen. Dit is niet
wenselijk, want in integratietesten zou I/O mogelijk kunnen zijn, omdat juist de
integratie van de verschillende onderdelen van het systeem getest worden.
Integratietesten zouden per definitie trager dan unittesten moeten zijn.
Afrondend
We kunnen concluderen dat het stellen van een class als harde grens ertoe leidt dat
unittesten sneller worden verwijderd en vervangen door nieuwe. En dat de kans groter
is dat er bugs geïntroduceerd worden door aannames, die na een aantal aanpassingen niet
meer kloppen. De classes zelf voldoen wel aan het gedrag dat ze moeten doen, maar
gezamenlijk als de business rule die waarde creëert, niet meer.
Als ontwikkelaars worden wij betaald om waarde te creëren en niet om zomaar stukken code
te schrijven. Het is dus niet alleen de verantwoordelijkheid van Test Engineers om deze
waarde te waarborgen. Deze verantwoordelijkheid ligt ook bij ons. Het is dus een noodzaak
dat wij een goede discipline hebben in maken van automatische testen; er niet voor zorgen
dat er een waardevermindering optreedt; en dat we ook op een goede en zekere manier nieuwe
waarde kunnen toevoegen.
Wanneer je unittesten gaat maken stel dan de volgende vragen: Is er een business rule voor
de klant die deze test garandeert? Zou ik deze test alleen hoeven aan te passen als er nieuw
gedrag gevraagd wordt met betrekking tot de business rule of de publieke interface van de
module wijzigd? Biedt de test mij de mogelijkheid om de software te verbeteren zolang de
business rule blijft werken? Kan ik erop vertrouwen dat als de test rood wordt, dat ik dan
de business rule kapot heb gemaakt? Als jij en je collega’s op deze vragen JA kunnnen
antwoorden dan weet je ook zeker dat je test ertoe doet en dat je kwaliteit behoudt.
Waarde, kwaliteit en stabiliteit is wat de klant op prijsstelt, niet zo zeer de opdeling in
classen en het aantal regels code dat de software heeft.
Broncode
Link naar de voorbeeld code: UnitTestExample.zip
Referenties
Boek
- Clean Architecture: A Craftsman’s Guide to Software Structure and Design geschreven door Robert C. Martin
Video’s
Blogs