Hoe voorkom je een technisch rampenproject?

Gepubliceerd: 19 november 2019

Tim Commandeur

Developer

Stel je het volgende scenario voor. Je staat op het punt een operatie te krijgen. De arts loopt naar je toe en zegt: “We hebben vanochtend nieuwe apparatuur gekregen voor deze operatie. We hebben die voor 10% getest, maar we denken dat het wel werkt.”. Geen fijn idee toch? Voor de meeste systemen is de impact van een bug vaak niet fataal, maar het kan wel veel geld kosten. Daarom is het belangrijk dat je controle en inzicht hebt in de kwaliteit van je software. In dit artikel leg ik uit waar vaak de problemen liggen en hoe je code codeonderhoud.

Spaghetti code

Als men code niet goed ordent, ontstaat er chaos. Code die niet te volgen is noemen we ook wel spaghetticode. Op het moment dat de code spaghetti is geworden, wordt onderhoud ontzettend duur. Niemand kan namelijk goed overzien hoe de code goed werkt. Op dat punt is het vergelijkbaar met het spelletje untangled (https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/untangle.html ). Probeer die maar eens te spelen met meer dan 1000 punten. Naast de hoge kosten is de code ook foutgevoeliger. Als iemand namelijk niet goed kan inschatten hoe code werkt, kan diegene makkelijk bestaande functionaliteit omver stoten bij een aanpassing ervan.

OK. Spaghetti code is slecht. Maar hoe voorkom ik het?

Spaghetti voorkom je door code te refactoren. Refactoren is het herstructureren en/of herschrijven van stukken code om het geheel op te schonen. Dit zorgt ervoor dat de code goed te begrijpen blijft door developers. Nu zal je nu misschien denken: “Maar waarom programmeer je het niet gewoon meteen goed vanaf het begin?”. Het antwoord daarop is dat niemand de toekomst kan voorspellen. Dus niemand kan alle knelpunten voorzien. Dit is vooral zo in Agile development, waarbij je niet vooraf weet waar je met je project zal staan na een lange periode van doorontwikkeling. Refactoren is dus ontzettend belangrijk. Maar je kunt in code geen wijzigingen maken, als je niet kunt zien wat de impact daarvan is. Misschien stoot je wel iets om. Dit is waarom developers zich er in sommige gevallen niet aan wagen. Als je iets kapot maakt ben je er namelijk ook verantwoordelijk voor. En niemand wil de blaam treffen als er een onderdeel kapot is. Het resultaat is dan dat niemand bestaande code onderhoudt, en in plaats daarvan voegen developers alleen maar code toe. Als dit een tijdje doorgaat krijg je geheid spaghetticode. De oplossing is om de code wel te onderhouden en grondig te testen. Zo kun je zien of er na het onderhoud niets kapot is gegaan. Belangrijk is een snelle testuitvoering, zodat een developer vroegtijdig kan zien of wijzigingen iets kapot maken.

Handmatige of automatische test?

Software testen kan handmatig door een mens of automatisch door een computer gedaan worden. Beide hebben voordelen. Handmatig testen is met name handig om te ervaren hoe je software werkt voor de eindgebruiker. Dit kan problemen met de gebruiksvriendelijkheid boven water halen. Handmatig testen kan ook ingezet worden om bugs in de applicatie te vinden, maar dit brengt problemen met zich mee. Je kunt met handmatige tests namelijk nooit 100% van je applicatie op regelmatige basis testen. Ga maar na hoe lang het duurt om alle scenario’s af te gaan van je applicatie. Bijvoorbeeld het testen van een simpel inlog-formulier. In eerste instantie verwacht je misschien dat het genoeg is om naar het inlogscherm te gaan en in te loggen. Maar als de volledige test voor het inlog-formulier enkel uit die stap zou bestaan, mis je ontzettend veel randscenario’s zoals:

  • Als het opgegeven account niet bestaat moet ik daar een melding van krijgen.
  • Als het opgegeven wachtwoord fout is moet ik daar een melding van krijgen.
  • Als het opgegeven account is geblokkeerd moet ik daar een melding van krijgen.
  • Als ik te veel foute aanmeld pogingen achter elkaar doe moet het systeem het account voor enkele minuten blokkeren. Dit voorkomt dat hackers mijn wachtwoord kunnen bruteforcen.
  • Als ik voor het eerst inlog vanaf een nieuw apparaat, moet het systeem valideren of ik het wel echt ben. Dit gebeurt per e-mail.
  • Als ik voor een tweede of latere keer inlog op een apparaat, moet het systeem niet valideren of ik het wel echt ben. Dit weet het systeem namelijk al.

Dit zijn ontzettend veel scenario’s, en nu zijn we nog niet eens voorbij het login scherm. Laat staan de hoofdfunctionaliteit van de applicatie. Daarom is het slim om ook automatische tests in te zetten. Automatische tests zijn namelijk consistent hetzelfde. Dat betekent dat al deze scenario’s maar een keer geprogrammeerd hoeven te worden, waarna ze altijd allemaal uitgevoerd kunnen worden met een druk op de knop.

Unit Tests

Automatische testen kent meerdere vormen. Maar vandaag zullen we het met name hebben over de snelste vorm: unit tests. De volgende code zal in de komende voorbeelden getest worden:

PHP

1class BlogPost
2{
3    /**
4     * @var Author
5     */
6    private $author;
7
8    /**
9     * @var bool
10     */
11    private $published;
12
13    /**
14     * @var string
15     */
16    private $body;
17
18    public function __construct(Author $author)
19    {
20        $this->author;
21    }
22
23    public function publish(): void
24    {
25        $this->published = true;
26    }
27
28    public function isPublished(): bool
29    {
30        return $this->published;
31    }
32
33    public static function makeForAuthor(Author $author): self
34    {
35        return new static($author);
36    }
37
38    public function getBody(): string
39    {
40        return $this->body;
41    }
42
43    public function setBody(string $body): void
44    {
45        $this->body = $body;
46    }
47
48    public function getAuthor(): Author
49    {
50        return $this->author;
51    }
52}
53class Author
54{
55    public function writeBlogPost(string $blogBody): BlogPost
56    {
57        $blogPost = BlogPost::makeForAuthor($this);
58        $blogPost->setBody($blogBody);
59
60        return $blogPost;
61    }
62}
63

Unit tests testen kleine stukken code in de applicatie (units). In onze applicaties zien we kleine stukken business rules als een unit. Bijvoorbeeld het publiceren van een blog. De test ziet er dan als volgt uit:

PHP

1
2public function testPublishBlogPost()
3{
4    // Set up the data.
5    $blogPost = new BlogPost();
6
7    // Do the action.
8    $blogPost->publish();
9
10    // Validate that the action has the correct result.
11    $this->assertTrue($blogPost->isPublished());
12}
13

Deze test is kort en simpel, en kijkt naar een klein stukje business logica. Bij het maken van dit soort tests hanteren we de volgende uitgangspunten:

1. Een test moet overeenkomen met hoe je applicatie wordt gebruikt

In een blog-systeem wordt een blogpost geschreven door een auteur. Dus een van de scenario’s die we willen testen, is of een auteur een blogpost kan schrijven.

PHP

1
2public function testAuthorWritesBlog()
3{
4    // Set up the data.
5    $author = new Author();
6    $blogPostBody = 'Some text in the blog post.';
7
8    // Do the action.
9    $blogPost = $author->writeBlogPost($blogPostBody);
10
11    // Validate that the action has the correct result.
12    $this->assertEquals(
13        $blogPostBody,
14        $blogPost->getBody()
15    );
16    $this->assertEquals(
17        $author,
18        $blogPost->getAuthor()
19    );
20}
21

Deze test is simpel en valideert een aantal belangrijke punten. Namelijk of een auteur een blog post kan maken. Maar ook dat de blog post een body tekst toegewezen kan krijgen en of de body terug komt bij het opvragen ervan.

2. Een test moet observeerbaar gedrag testen en NIET implementatie

Sommige developers willen een unit afbakenen per class. Dit gaat echter enorme nadelige gevolgen hebben voor de kwaliteit van de testset. Stel we pakken het simpele voorbeeld van hierboven, waarbij we kijken of een auteur een blogpost kan schrijven. Maar nu zeggen we: “De test mag enkel de Author class testen en niet gebruik maken van de BlogPost class omdat die niet relevant is voor de test.” Ten eerste zal de simpele code base die we hierboven hadden dan niet meer voldoen, dit zal dan moeten worden:

PHP

1
2class BlogPostBuilder
3{
4    public function buildForAuthor(Author $author): BlogPost
5    {
6        return new BlogPost($author);
7    }
8}
9
10class Author
11{
12    public function writeBlogPost(
13        BlogPostBuilder $blogPostBuilder,
14        string $blogBody
15    ): BlogPost
16    {
17        $blogPost = $blogPostBuilder->buildForAuthor($this);
18        $blogPost->setBody($blogBody);
19
20        return $blogPost;
21    }
22}
23

Vervolgens wordt onze test:

PHP

1public function testWriteBlog()
2{
3    // Set up the data but do some assertions with
4    // shouldBeCalled() also.
5
6    $author = new Author();
7    $blogPostBody = 'Some text in the blog post.';
8    $blogPost = $this->mock(BlogPost::class);
9    $blogPost
10        ->setBody($blogPostBody)
11        ->shouldBeCalled();
12    $blogPostBuilder = $this->mock(BlogPostBuilder::class);
13    $blogPostBuilder
14        ->buildForAuthor($author)
15        ->shouldBeCalled()
16        ->willReturn($blogPost->reveal());
17
18    // Do the action.
19    $blogPost = $author->writeBlogPost(
20        $blogPostBuilder->reveal(),
21        $blogPostBody
22    );
23
24    // Validate that the action has the correct result.
25    $this->assertEquals(
26        $blogPost->reveal(),
27        $blogPost->getBody()
28    );
29}
30

De bovenstaande test is onhandig om meerdere redenen.

  • Het is moeilijk om te zien wat deze test nou precies test. Het eerste voorbeeld was makkelijk te overzien, en kon daarom ook dienen als een stuk documentatie van die code. Dat voordeel is volledig komen te vervallen bij deze tweede setup.
  • De test uit het tweede voorbeeld kostte mij 15 minuten om uit te werken, terwijl de test uit het eerste voorbeeld mij ongeveer 1 minuut kostte.
  • Ze moeten observeerbaar gedrag testen en NIET implementatie.
  • De test uit het tweede voorbeeld kijkt naar implementatie. Bij het eerste voorbeeld zou het herschrijven van een stukje interne logica makkelijk zijn. Even een automatische refactor op bijvoorbeeld de functienaam setBody zou geen invloed hebben op die test. In het tweede voorbeeld echter, zou een refactor met het hernoemen van de setBody methode de test direct kapot maken. Dat betekent dat we naast het verwerken van de implementatie ook voortdurend de test moeten onderhouden. Maar erger nog, we kunnen de test niet gebruiken om te valideren of de code nog wel correct werkt na het refactoren.

Wat zien unit tests niet?

We hebben in mijn team heel veel gemak ondervonden van deze tests. Er zijn echter wel een paar kanttekeningen. De tests zijn namelijk volledig blind voor alle database communicatie. Dit betekent dat zaken als sortering en filtering van lijstweergave niet getest is met deze unit tests. Maar ook dingen als hoe objecten ingeschoten worden in de database, en hoe gerelateerde objecten zich gedragen als er een object wordt verwijderd, zijn niet gedekt door deze tests.

Conclusie

Bugloze software bestaat niet, maar je kunt de risico’s wel minimaliseren door goede automatische tests neer te zetten en de code regelmatig te onderhouden. Belangrijke uitgangspunten bij het opzetten van automatische tests:

  • Ze moeten snel zijn.
  • Ze moeten overeenkomen met hoe het systeem gebruikt wordt.
  • Ze moeten observeerbaar gedrag testen en NIET implementatie.

Dit heeft mij als ontwikkelaar het afgelopen jaar enorm veel rust gegeven in mijn development. In sommige situaties kon ik met unit tests zelfs sneller een feature ontwikkelen, omdat ik heel snel geïsoleerd een onderdeel kon opzetten in ons systeem. Daarnaast kon ik met weinig risico de code refactoren waar nodig. Daarom wil ik dit artikel graag eindigen met deze opmerking:

Je bent een rund als je geen tests runt

Opmerkingen

Er zijn momenteel nog geen opmerkingen geplaatst onder deze blog.
Wil jij als eerste een bericht achter laten?

Laat een bericht achter
Aantal tekens:0 / 500

Beveiligd met reCAPTCHA.PrivacyVoorwaarden