Etikettarkiv: unit testing

Fluent Assertions gör dina tester enklare att förstå


Idag är det en självklarhet att skriva automatiserade tester vid utveckling. Många tillämpar även TDD (Test Driven Development). Testerna ger en säkerhet när man arbetar med koden eftersom de talar om när något gått sönder. Välskrivna tester kan även fungera som dokumentation. Nya utvecklare kan skapa sig en bild av vad systemet gör genom att kolla på testerna. Genom att använda Fluent Assertions så blir valideringen av testerna mycket lättläst och enkel att följa i jämförelse med de mer traditionella metoderna som erbjuds i många testramverk.

Uppbyggnad av ett test

Normalt delar man in ett test i tre olika delar:

  • Arrange – skapar de förutsättningar som testet kräver i form av resurser och tjänster
  • Act – utför det som ska testas
  • Assert – verifierar utfallet av testet

Så vad gör Fluent Assertions?

Fluent Assertions är ett .NET-bibliotek som riktar in sig på den sista delen som gäller ”Assert”. Vanligtvis använder man testramverkets Assert-metoder för att verifiera resultatet av testen. En nackdel med dessa metoder är att testerna blir väldigt tekniska till sin natur och det kan vara svårt att se exakt vad man testar. Med Fluent Assertions så blir koden för att verifiera resultaten mycket enklare att förstå. Fluent Assertions är egentligen ett bibliotek med ”extension methods”. Det gör att man kan kedja ihop flera olika anrop och på så sätt få ett mycket mer uttrycksfullt sätt att skriva valideringskoden.

Test av en kalkylator

Som exempel så testar vi följande klass som är en mycket enkel kalkylator.


using System;
using System.Collections.Generic;

namespace CalculatorLib
{
  public class Calculator
  {
    private Stack _history = new Stack();

    public int Memory
    {
      get;
      set;
    }

    public IEnumerable History
    {
      get { return _history; }
    }

    public Calculator()
    {
    }

    public int Add(int num1, int num2)
    {
      var result = num1 + num2;
      _history.Push(result);
      return result;
    }

    public int Subtract(int num1, int num2)
    {
      var result = num1 - num2;
      _history.Push(result);
      return result;
    }

    public int Multiply(int num1, int num2)
    {
      var result = num1 * num2;
      _history.Push(result);
      return result;
    }

    public double Divide(int num1, int num2)
    {
      if (num2 == 0)
        throw new ArgumentException("Zero is not allowed", nameof(num2));

      return Convert.ToDouble(num1) / Convert.ToDouble(num2);
    }
  }
}

Tester skrivna med nUnits assert-metoder

Såhär skulle tester kunna se ut om de skrivs på vanligt sätt med nUnits assert-metoder.


using CalculatorLib;
using NUnit.Framework;
using System;
using System.Collections.Generic;

namespace CalculatorLibraryTest
{
  [TestFixture]
  public class ClassicCalculatorLibTests
  {
    [Test]
    public void TestAddition()
    {
      // Arrange
      var calculator = new Calculator();

      // Act 
      var result = calculator.Add(2, 4);

      // Assert
      Assert.AreEqual(6, result, "incorrect sum of 2 + 4");
    }

    [Test]
    public void TestSubtraction()
    {
      // Arrange
      var calculator = new Calculator();

      // Act 
      var result = calculator.Subtract(10, 3);

      // Assert
      Assert.AreEqual(7, result, "incorrect difference of 10 - 3");
    }

    [Test]
    public void TestMultiplication()
    {
      // Arrange
      var calculator = new Calculator();

      // Act 
      var result = calculator.Multiply(5, 3);

      // Assert
      Assert.AreEqual(15, result, "incorrect product of 5 * 3");
    }

    [Test]
    public void TestDivision()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      var result = calculator.Divide(15, 3);

      // Assert
      Assert.AreEqual(5d, result, "incorrect quote of 15 / 3");
    }

    [Test]
    public void TestDivisionByZero()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      Action act = () => calculator.Divide(15, 0);

      // Assert
      ArgumentException ex = Assert.Throws(new TestDelegate(act));
      Assert.That(ex.Message, Is.StringStarting("Zero is not allowed"));
      Assert.That(ex.ParamName, Is.EqualTo("num2"));
    }

    [Test]
    public void TestMemory()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      var sum = calculator.Add(2, 9);
      calculator.Memory = sum;
      var result = calculator.Memory;

      // Assert
      Assert.AreEqual(11, result, "incorrect value in memory");
    }

    [Test]
    public void TestResultHistory()
    {
      // Arrange
      var calculator = new Calculator();
      var sum = calculator.Add(1, 3);
      var difference = calculator.Subtract(5, 2);
      var product = calculator.Multiply(6, 3);
      var expected = new Stack();
      expected.Push(sum);
      expected.Push(difference);
      expected.Push(product);

      // Act
      var history = calculator.History;

      // Assert
      CollectionAssert.IsNotEmpty(history);
      CollectionAssert.AreEqual(expected, history);
    }
  }
}

I detta fall använder vi metoden Assert samt CollectionsAssert för att validera utfallet av testet. Även om det går att förstå vad koden gör så kräver det en del tankeverksamhet för att förstå vad som testas och framförallt vad det förväntade resultatet ska vara.

Tester skrivna med Fluent Assertions

Så här kan motsvarande tester skrivas med Fluent Assertions.


using NUnit.Framework;
using FluentAssertions;
using CalculatorLib;

using System;
namespace CalculatorLibTest
{
  [TestFixture()]
  public class CalculatorTest
  {
    [Test()]
    public void TestAddition()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      var result = calculator.Add(2, 4);

      // Assert
      result.Should().Be(6,"because it's the sum of 2 and 4");
    }

    [Test]
    public void TestSubtraction()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      var result = calculator.Subtract(10, 3);

      // Assert
      result.Should().Be(7, "because it's the difference between 10 and 3");
    }

    [Test]
    public void TestMultiplication()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      var result = calculator.Multiply(5, 3);

      // Assert
      result.Should().Be(15, "because it's the product of 5 and 3");
    }	

    [Test]
    public void TestDivision()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      var result = calculator.Divide(15, 3);

      // Assert
      result.Should().Be(5.0d, "because it's the quote of 15 and 3");
    }

    [Test]
    public void TestDivisionByZero()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      Action act = () => calculator.Divide(15, 0);

      // Assert
      act.ShouldThrow("because division by zero is not allowed")
        .Where(e => e.Message.StartsWith("Zero is not allowed"))
        .And.ParamName.Should().BeEquivalentTo("num2");
    }

    [Test]
    public void TestMemory()
    {
      // Arrange
      var calculator = new Calculator();

      // Act
      var sum = calculator.Add(2, 9);
      calculator.Memory = sum;
      var result = calculator.Memory;

      // Assert
      result.Should().Be(11, "because the sum of 2 and 9 should be stored in memory");
    }

    [Test]
    public void TestResultHistory()
    {
      // Arrange
      var calculator = new Calculator();
      var sum = calculator.Add(1, 3);
      var difference = calculator.Subtract(5, 2);
      var product = calculator.Multiply(6, 3);

      // Act
      var history = calculator.History;

      // Assert
      history.Should().NotBeEmpty()
        .And.HaveCount(3).And
        .ContainInOrder(product,difference,sum);
    }
  }
}

Fluent Assertions låter oss tala om vad utfallet borde vara (därav metoden Should()). Vi kan sedan länka på ytterligare villkor som vi förväntar oss ska vara uppfyllda. Det är ganska enkelt att förstå vad det förväntade utfallet ska vara genom att bara läsa koden. Fluent Assertions gör att koden blir lätt att förstå även för någon som inte kan programmering.

Vad kan Fluent Assertions göra mer?

Fluent Assertions kan användas för att validera struktur i projekt t.ex. att projekt refererar varandra på rätt sätt t.ex. att presentationslager endast refererar affärslogikslagret och att det i sin tur refererar datalagret. På så sätt kan man bygga in tester för arkitektur. Det finns även tillägg till Fluent Assertions som kan testa ”dependency injection” som t.ex. ninject.

Läs mer på Fluent Assertions

Automatisera från dag ett

Automatisering är ett bra sätt att minska tid och resurser som behövs för felsökning. En automatiserad process görs alltid på samma sätt och inga steg kommer att missas. Dessutom frigörs resurser till att göra viktigare saker då de slipper göra en massa manuella steg om och om igen. Ju tidigare man börjar automatisera desto bättre. Ju senare man börjar desto svårare kommer det att bli att eftersom de processer som skapats inte har designats för att automatiseras.

Automatiserade enhetstester

Om man inte har några enhetstester måste man testa koden genom att starta hela systemet och köra igenom olika testfall manuellt. Dels tar det tid att hela tiden starta systemet för att testa, stänga av systemet för att rätta eventuella fel, kompilera om och starta systemet igen och testa om. Då det blir för komplext och trögt att testa så kommer det inte att göras och eventuella fel upptäcks långt efteråt vid systemtestning. Dessutom finns en stor risk för att man glömmer något test eller inte utför det på exakt samma sätt som föregående körningar.

Automatiserade enhetstester kräver inte att systemet körs och kan ofta köras igång direkt från utvecklingsmiljön genom att trycka på en knapp. Testerna kommer alltid att köras på samma sätt och blir det fel så får man direkt feedback och kan rätta och köra om samtliga tester igen. På detta sätt så tar man effektivt bort småfel som annars skulle slippa igenom till systemtestning.

Enhetstester måste skrivas från dag ett i projektet. Att försöka skriva enhetstester i efterhand är komplext eftersom koden oftast inte skrivits på ett sätt som gör att den är testbar.

Blir det inte dyrt att lägga tid på att skriva automatiserade tester? Det är väl bättre att utvecklarna skriver ny funktionalitet? Automatiserade tester kräver naturligtvis en del tid att skriva men det är tid som sparas i senare steg genom att vi slipper lägga tid och resurser på komplex felsökning. Dessutom är det ett utmärkt verktyg att ha när systemet ska vidareutvecklas eftersom det också fungerar som regressionstest.

Automatiserat bygge

Att bygge ett system som består av flera olika moduler är en komplex uppgift. Om det dessutom är ett större utvecklingsteam som arbetar på olika delar av systemet så finns en risk att problem uppstår när de olika delarna ska integreras.

Det finns en hel del verktyg för att automatisera byggandet av system (ant, maven, msbuild o.s.v.). Det blir då enkelt att schemalägga byggen så att man bygger hela systemet åtminstone en gång per dygn och kör samtliga automatiserade tester för att fånga upp problem. Störst nytta får man dock om man dessutom använder en contiuous integration server som bygger systemet och kör alla enhetstester så fort en incheckning sker i versionshanteringssystemet. Då fångas eventuella integrationsproblem upp direkt.

Automatiserad deploy

När det närmar sig driftsättning av system så brukar det vara febril aktivitet. Det skrivs checklistor och planer för utrullning. Det bokas resurser som ska genomföra installationerna och testresurser som ska verifiera att allt ser bra ut. Nödvändig infrastruktur införskaffas och konfigureras.

I en del fall är driftsättning något som sker utan någon som helst generalrepetition för systemadministratörer och it-tekniker. Det är lite konstigt att ta en sådan risk med tanke på alla resurser lagts ned på att utveckla systemet. Även om det har funnits tillfälle att öva på processen i en produktionslik miljö så finns risken att man missar viktiga moment om det är mycket som ska göras.

Ju längre man väntar med att automatisera deployment och konfiguration av system desto svårare blir det att göra. Genom att redan från första dagen i projektet automatisera deployment processen så hinner man köra igenom den flera gånger under projektets gång. Målet bör vara att en driftsättning är så enkel att man bara trycker på en knapp för att genomföra det. Fördelen är att processen alltid kommer att genomföras på samma sätt vilket eliminerar att något steg missas och risken minskar för en misslyckad driftsättning. Dessutom är det ett utmärkt sätt att säkerställa att man kan återställa ett system vid en eventuell katastrof.

Att använda MSTest med Continuous Integration

I Visual Studio ingår ett testramverk kallat MSTest. Det har ungefär samma struktur som andra testramverk som t.ex. NUnit. Det finns dock en skillnad mellan ramverken när man vill köra tester på en byggagent via Continuous Integration. För NUnit lägger man oftast in dll:en för NUnit tillsammans med sin kod och pekar sedan ut den i bygget.

MSTest kan inte packas in som en del av koden utan måste man installera Visual Studio på byggagenten. Det är oftast något som åtminstone jag vill undvika. Att ha IDE installerat på byggagenten ökar risken att få med sig IDE-specifika beroenden som man inte vill ha. Från och med Visual Studio 2010 finns ett paket kallat Visual Studio Agents. Detta kan man installera på noder som ska köra automatiserade tester. Det känns som en bättre lösning än att installera hela Visual Studio.

Själv föredrar jag att använda NUnit i mina projekt eftersom det känns mer lättviktigt och portabelt. Om jag ändå måste köra MSTest så använder jag Visual Studio Agents.

Att undvika långsamma byggen

LångsammaByggenAutomatisering av byggen är en viktig del i Continuous Integration. Det är dock inte en engångsaktivitet. Man behöver följa upp hur bygget mår över tid. Allt eftersom man kommer längre in i projektet så tenderar det att ta längre tid att bygga systemet och köra alla tester. Eftersom det är viktigt att få snabb feedback på byggen så måste man analysera vad som tar tid och försöka hitta lämpliga åtgärder.

Om man har moduler som tar lång tid att bygga men som inte förändras ofta så kan man överväga att bryta ut dessa från huvudbygget och låta dem byggas i ett sekundärt bygge.

Enhetstester som körs i samband med bygget ska vara snabba att köra. Analysera om det finns tester som tar lång tid och se om de kan optimeras eller om de ska göras om till komponent- eller systemtester som kan köras i ett sekundärt bygge.

Om flera utvecklare uppdaterar versionshanteringssystemet samtidigt kan det uppstå kö på CI-servern vilket gör att man inte får tillräckligt snabb feedback (ett integrationsbygge bör ta max 10 minuter). Om man ser att det ofta blir köbildning så kan man eventuellt utöka resurserna med flera maskiner som kan bygga. Då kan man sprida ut lasten på olika noder. Detta är också att föredra om man använder sekundära byggen eftersom dessa ofta tar längre tid så bör de köra på en annan nod än huvudbygget som måste köra snabbt.