Blog

Wat is wel de unit in een unittest? (deel 2)

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

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

[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

[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

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

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

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

[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

[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

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

[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

[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