Skip to content

C# - testning del 2

Nu ska vi fördjupa oss i enhetstestning i NUnit och träna på att skriva enhetstester. I C# - testning gick vi igenom enhetstestning, hur vi skapade ett testprojekt med några enhetstester samt körde testerna. Läs igenom den artikeln som repetition.

Anledningen till att vi enhetstestar är att verifiera att vårt program gör det som kraven specificerar. klassen med dess attribut och metoder gör det den ska. Enhetstester säkerställer att varje del av koden fungerar som den ska. Dessutom har flera studier visat att ju tidigare vi hittar buggarna desto billigare blir utvecklingskostnaden. Enhetstesterna kan ses som en slags dokumentation på vad klassen gör.

Image description

Nackdelen är att utvecklingen tar lite längre tid och att det kräver kunskap om C#, kraven och NUnit för att skriva testfall.

NUnit är ett open-source testramverk för enhetstestning och användbart för C# och andra .NET-språk. Med NUnit skapar vi ett testprojekt med testklasser. Vi har en eller flera testklasser för en klass. NUnit erbjuder ett testbibliotek med konstruktioner för att skapa tester och för att verifiera med assertmetoder.

NUnit projektet är en del av .NET Foundation som ger vägledning och stöd för att säkerställa NUnits projektets framtid. Aktuell version är NUnit 3 som skapades av Charlie Poole, Rob Prouse, Simone Busoli och Neil Colvin samt flera andra har bidragit till förbättringar.

I vårt fall så har vi valt NUnit för vår enhetstestning och våra tester ska vara automatiska. Vi testar ett program med 4 klasser; Bank, BankAccount, InterestAccount och PayrollAccount.

Vi skriver testfall genom att:

  1. skapa ett tillstånd (Arrange)
  2. utföra en eller flera handlingar (Act)
  3. verifiera utfallet (Assert).

Olika attribut identifierar testklasser och testmetoder. Här är några exempel:

  • TestFixture, identifierar att klassen är en testklass
  • Test, identifierar att det är en testmetod
  • TestCase, identifierar att det är en testmetod med inparametrar
  • Setup, identifierar en metod som körs före varje testfall
  • Teardown, identifierar en metod som körs efter varje testfall
  • OneTimeSetup, identifierar en metod som körs en gång först i testklassen
  • OneTimeTearDown, identifierar en metod som körs en gång sist i testklassen

Här är beskrivningar av alla attribut.

Vi kommer att använda den nyare modellen, “Constraint model” då den har tydligare felmeddelanden och fortfarande är under utveckling. I NUnit är använder vi “constraints” för att verifiera ett utfall (assertion). Några exempel på användbara “constraints”:

  • Equal, jämför om värden är lika. Exemplen nedan visar fler “constraints” Not och IgnoreCase.
Assert.That(2 + 2, Is.EqualTo(4.0));
Assert.That(2 + 2, Is.Not.EqualTo(5));
Assert.That("Hello!", Is.Not.EqualTo("HELLO!")); // Not constraint
Assert.That("Hello!", Is.EqualTo("HELLO!").IgnoreCase); // IgnoreCase constrain
  • True, verifierar om något är sant och det finns även False som verifierar om något är falskt.
flag = true;
Assert.That(flag, Is.True); // testfallet går rätt
Assert.That(flag, Is.False); // testfallet går fel
  • SubString, verifierar om utfallet innehåller en substring.
string phrase = "Make your tests fail before passing!";
Assert.That(phrase, Does.Contain("tests fail"));
Assert.That(phrase, Does.Not.Contain("tests pass"));
Assert.That(phrase, Does.StartWith("Make")); // StartWith constraint
Assert.That(phrase, Does.EndWith("!"));
  • Or, verifierar om antingen det ena eller det andra utfallet är korrekt och i så fall blir testet korrekt.
Assert.That(3, Is.LessThan(10).Or.GreaterThan(5));
  • Count, verifierar att listan har ett visst antal element.
Assert.That(list, Has.Count.EqualTo(3)); // om listan innehåller exakt 3 element
Assert.That(list, Does.Contain(5)); // om listan innehåller värdet 5

Här finns också GreaterThan, LessThan, False och liknande constraints. Läs mer om olika Constraints.

NUnit erbjuder också TestCaseAttribute för att tillhandahålla inline-data till testmetoder. Här är ett exempel på hur man använder det:

[TestCase(12, 3, 4)]
[TestCase(12, 2, 6)]
[TestCase(12, 4, 3)]
public void DivideTest(int n, int d, int q)
{
Assert.That(n / d, Is.EqualTo(q));
}

Första gången testfallet körs så divideras 12 med 3 och svaret ska bli 4. Andra gången testfallet körs så divideras 12 med 2 och svaret ska då bli 6.

Men vill du köra testerna enklare i VS Code så kan du installera en extension som heter .NET Core Test Explorer.

Vi börjar med projektet vi ska testa. Först skapar vi en katalog som heter Testing i practice-katalogen med mkdir Testing. Därefter kopierar vi vår egen kod från MyBank med cp -r MyBank Testing/MyBank2.

För att köra tester skapar vi ett testprojekt MyBank.Tests som ska testa vårt bankprojekt MyBank2. MyBank låter vi vara för det tillhör artikeln om arv. När vi börjar denna artikel så har MyBank2 samma innehåll som MyBank men olika namespaces.

Det skapas en testklass när vi skapar vårt testprojekt och den byter vi namn på till BankAccountTests.cs. Vi lägger också till en referens till vårt bankprojekt så att testklasserna hittar koden som ska testas i MyBank2.

Skapa ett testprojekt MyBank2.Tests i katalogen kmom05/practice/Testing
dotnet new nunit -o MyBank2.Tests
cd MyBank2.Tests
mv UnitTest1.cs BankAccountTests.cs
dotnet add reference ../MyBank2/MyBank2.csproj

Katalogstrukturen i Testing ser ut så här:

Section titled “Katalogstrukturen i Testing ser ut så här:”
// stå i kmom05/Testing/MyBank2.Tests
tree .. -L 2 // för att titta på MyBank2 och MyBank2.Tests
.
├── MyBank2
│ ├── MyBank2.csproj
│ ├── Program.cs
│ ├── src
│ ├── bin
│ └── obj
└── MyBank2.Tests
├── BankAccountTests.cs
├── MyBank2.Tests.csproj
├── bin
└── obj

Dubbelkolla att referensen till MyBank2 finns i projektfilen MyBank2.Tests.csproj till MyBank2.Tests. Dessutom kan filen MyBank2.Tests.sln också finnas och den används i Visual Studio men inte i VS Code, som vi använder. Du kan ta bort MyBank2.Tests.sln.

Kör att testfallen, stå i testprojektet MyBank2.Tests
dotnet test
dotnet test --logger "console;verbosity=normal" // kör testerna med snygg utskrift
Kör att enstaka testfall, stå i testprojektet MyBank2.Tests
dotnet test --filter Create // kör testfall med Create i testnamnet
dotnet test --filter Name~TestCreateBankAccount // kör testfallet med namnet TestCreateBankAccount
dotnet test --filter Priority=2 // kör tester som kommenteras med [Priority(2)].

Vi läser i testplanen Testplan - Bankexemplet för klassen BankAccount och skapar det första testfallet som ska verifiera att en instans av klassen BankAccount skapas och att antalet konto i banken är noll.

Koden för testklassen BankAccountTests med testfallet TestCreateBankAccount.

Section titled “Koden för testklassen BankAccountTests med testfallet TestCreateBankAccount.”
// i kmom05/MyBank2.Tests/BankAccountTests.cs
using MyBank2.src;
[TestFixture]
public class BankAccountTests
{
[Test]
public void TestCreateBankAccount()
{
// Arrange
var account = new BankAccount("Test person", 1000);
// Assert and no Act
Assert.That(account.GetOwner(), Does.Contain("Test person"));
Assert.That(account.GetBalance(), Is.EqualTo(1000));
}
}

Testkör och se att testfallet gick bra. Testkör de olika kommandona ovan för att verifiera detta första testfall. Det är bra att kunna köra ett specifikt testfall när det utvecklas.

Nu går vi vidare till nästa testfall enligt testplanen och det är TestCreateBankAccountNegative.

Koden för testfallet TestCreateBankAccountNegative.

Section titled “Koden för testfallet TestCreateBankAccountNegative.”
// i kmom05/Testing/MyBank2.Tests/BankAccountTests.cs
using MyBank2.src;
[TestFixture]
public class BankAccountTests
{
[Test]
public void TestCreateBankAccountNegative()
{
// Arrange & Assert
Assert.That(() => new BankAccount("Test person", -1000),
Throws.TypeOf<CreateBankAccountException>()
.With.Message.EqualTo("Bankkontot ska skapas med saldo >= 0."));
}
}

Vi testkör och upptäcker att vi får problem. Testerna kompilerar inte eftersom vi inte har ett Exception som heter “CreateBankAccountException”. Då lägger vi till ett sådant exception med rätt meddelande.

// i kmom05/Testing/MyBank2/src/CreateBankAccountException.cs
namespace MyBank2.src;
public class CreateBankAccountException : Exception
{
public CreateBankAccountException(string message) : base(message)
{
}
}

Eftersom vi ändrat i projektet MyBank2 så måste vi kompilera om det innan vi testkör testprojektet på nytt.

När vi kör testprojektet på nytt så kompilerar det, men testfallet går inte igenom. Vi undersöker varför. I testplanen står det att ett exception ska kastas när ett objekt av BankAccount skapas med negativt saldo. Objekt skapas med konstruktorn och där saknas kontrollen av saldot.

Då behöver vi uppdaterar konstruktorn i klassen BankAccount så att den kastar detta fel med felmeddelandet om någon försöker skapa ett BankAccount-objekt med negativt saldo.

Koden för konstruktorn till klassen BankAccount.

Section titled “Koden för konstruktorn till klassen BankAccount.”
// i kmom05/Testing/MyBank2/src/BankAccount.cs
namespace MyBank2.src;
public class BankAccount
{
private static int _accountNumberSeed = 1234567890;
protected string _owner;
protected decimal _balance;
private string _accountNumber;
public BankAccount(string name, decimal initialBalance)
{
if (initialBalance < 0)
{
throw new CreateBankAccountException("Bankkontot ska skapas med saldo >= 0.");
}
_owner = name;
_balance = initialBalance;
_accountNumber = _accountNumberSeed.ToString();
_accountNumberSeed++;
}
...

Kompilera om projektet MyBank2 och kör testprojektet på nytt. Nu går testfallet igenom.

Vi läser i testplanen Gör en testplan för Bankexemplet för klassen Bank och skapar det första testfallet som ska verifiera att en instans av klassen Bank skapas och att antalet konto i banken är noll.

Vi har ingen klass Bank i projektektet MyBank2 men vi skapar den så här.

// i kmom05/Testing/MyBank2/src/Bank.cs
namespace MyBank2.src;
public class Bank
{
public class Bank
{
public const string BANK_NAME = "Dbwebb Bank";
private List<BankAccount> _accounts;
public Bank(string bankName)
{
this._accounts = new List<BankAccount>();
}
public void AddAccount(BankAccount newAccount)
{
this._accounts.Add(newAccount);
}
public string GetNoOfAccounts()
{
return this._accounts.Count.ToString();
}
public string GetDescription()
{
return $"Banken heter {BANK_NAME} och har {GetNoOfAccounts()} konton.";
}
}

Här visas hela testklassen BankTests med testfallet TestCreateBank. I Setupmetoden skapas en instans av klassen Bank med namnet från konstanten BANK_NAME. I testfallet TestCreateBank verifierar vi att bankens namn blev det vi satte samt att antalet konto i en tom bank är 0.

Koden för testklassen BankTest med testfallet TestCreateBank:

Section titled “Koden för testklassen BankTest med testfallet TestCreateBank:”
// i kmom05/Testing/MyBank2.Tests/BankTests.cs
using MyBanks2.src;
[TestFixture]
public class BankTests
{
private Bank _bank;
private const string BANK_NAME = "Test bank";
[SetUp]
public void Setup()
{
// Arrange
_bank = new Bank();
}
[Test]
public void TestCreateBank()
{
// Assert and Act
Assert.That(Bank.BANK_NAME, Does.Contain(BANK_NAME));
Assert.That(_bank.GetNoOfAccounts(), Does.Contain("0"));
}
}

Vi testkör och nu går de tre testfallen igenom. Bra!

Vi läser vidare i testplanen och ser att nästa testfall är TestCreateBankDescription. Det är bra att testa beskrivningen i ett eget testfall eftersom den kan komma att ändras.

Koden för testfallet TestCreateBankDescription:

Section titled “Koden för testfallet TestCreateBankDescription:”
// i kmom05/Testing/MyBank2.Tests/BankTests.cs
using MyBanks2.src;
[TestFixture]
public class BankTests
{
...
[Test]
public void TestCreateBankDescription()
{
// Assert and Act
Assert.That(_bank.GetDescription(), Does.Contain($"Banken heter {Bank.BANK_NAME} och har {_bank.GetNoOfAccounts()} konton."));
}
}

Vi testkör och ser att testfallet ger grönt, det vill säga det går igenom.

För att mocka testfall i NUnit kan du använda NUnit’s inbyggda mock-bibliotek eller andra populära bibliotek som Moq eller Rhino Mocks.

Mockning används i mjukvarutestning för att simulera beteendet hos externa beroenden, som andra klasser, databaser eller filer. Istället för att kommunicera med en riktig databas så mockar vi databasen och kommunicerar med mockobjektet av databasen.

Fake är en generisk term som kan användas för att beskriva antingen en stubb eller ett mockobjekt.

Ett mockobjekt, mock, är ett objekt som imiterar beteendet hos riktiga objekt. Mockobjekt används för att verifiera interaktionerna mellan klasser eller beteendet hos klasser. Före starten av ett test ställer vi in förväntningarna på en mock och instruerar den att bete sig på ett visst sätt när något händer. Det innebär att vi kan kontrollera om vissa metoder har anropats, hur många gånger de har anropats och med vilka parametrar. Ett mockobjekt imiterar ett verkligt objekt.

Stubbing ger fördefinierade svar på anrop som görs under testet. Vi använder stubbar när vi vill ersätta ett beroende med en enkel implementation som returnerar fördefinierade värden. Det är mindre fokuserat på interaktion och mer på att tillhandahålla nödvändiga data för testet.

Vi börjar med att installera “Moq” för att kunna använda det.

Terminal window
// stå i testprojektet MyBank2.Tests
dotnet add package Moq

Verifiera att den är installerad genom att kolla i projektfilen.

Vi kan skapa en mock av ett objekt med hjälp av mock-biblioteket. Till exempel, om vi använder Moq kan vi skapa en mock med var mock = new Mock<YourClass>();.

Vi antar att “skapa konto” med klassen BankAccount har yttre beroenden och därför behöver vi mocka “skapa konto”.

Vi gör ett exempel och skapar testfallet TestAddAccounts. Testfallen finns beskrivna i artikeln Gör en testplan för Bankexemplet. Vi lägger till att vi använder Moq genom using Moq;.

// i kmom05/Testing/MyBank2.Tests/BankTests.cs
using Moq;
using MyBank.src;
[TestFixture]
public class BankTests
{
...
[Test]
public void TestAddAccounts()
{
// Arrange
var mockAccount1 = new Mock<BankAccount>("Owner 1", 12000m);
var mockAccount2 = new Mock<BankAccount>("Owner 2", 2000m);
// Act
_bank.AddAccount(mockAccount1.Object);
_bank.AddAccount(mockAccount2.Object);
// Assert
Assert.That(_bank.GetNoOfAccounts(), Does.Contain("2"));
}
}

I testfallet TestAddAccounts skapar vi ett mockobjekt för klassen BankAccount istället för ett objekt av själva klassen. var används här för att förenkla koden och göra den mer lättläst. Kompilatorn kan härleda att mockBank är av typen Mock<BankAccount> eftersom den tilldelas ett nytt objekt av denna typ. Inom <> efter new Mock så visar vi typen på mockobjektet, vilket i exemplet är BankAccount. För att enkelt hålla koll på att det är ett mockobjekt får det namnet “mockAccount” (med mock i början av namnet). Vi har bland annat skapat ett mockobjekt av typen BankAccount som heter “mockAccount1” och för att komma åt vårt BankAccount-objekt använder vi mockAccount1.Object.

// i kmom05/Testing/MyBank2.Tests/BankTests.cs
...
[Test]
public void TestAddAccounts()
{
// Arrange
var mockAccount1 = new Mock<BankAccount>("Owner 1", 12000m);
var mockAccount2 = new Mock<BankAccount>("Owner 2", 2000m);
// Act
_bank.AddAccount(mockAccount1.Object);
_bank.AddAccount(mockAccount2.Object);
// Assert
Assert.That(_bank.GetNoOfAccounts(), Does.Contain("2"));
}
}

I testfallet TestAddAccounts skapar vi ett mockobjekt för klassen BankAccount istället för ett objekt av själva klassen. var används här för att förenkla koden och göra den mer lättläst. Kompilatorn kan härleda att mockBank är av typen Mock<BankAccount> eftersom den tilldelas ett nytt objekt av denna typ. Inom <> efter new Mock så visar vi typen på mitt mockobjekt, vilket i exemplet är BankAccount. För att enkel hålla koll på att det är ett mockobjekt får det namnet “mockAccount” (med mock i början av namnet). Vi har bland annat skapat ett mockobjekt av typen BankAccount som heter “mockAccount1” och för att komma åt vårt BankAccount-objekt så använder vi mockAccount1.Object.

Nu har vi tittat på mer enhetstestning C#:

  • NUnit med assertions enligt den nyare “Constraint model”
  • att skriva testfall
  • att köra testfall (dotnet test), alla, en klass eller ett testfall
  • vi har provat på att använda .NET Core Test Explorer för att kunna köra testfall i VS code.
  • mocka klasser i testfall

Koden för Testing/MyBank2 laddar du ned det med task download-code -- kmom05/Testing/MyBank2 och koden för Testing/MyBank2.Tests laddar du ned det med task download-code -- kmom05/Testing/MyBank2.Tests. De båda projekten hamnar då i katalogen kmom05/Testing/codeExamples/.

Vill du läsa mer om enhetstester på Microsofts sätt, kolla här.