UnitTestCzęść NR.9 W tym wpisie zrobimy porównanie frameworków testów jednostkowych, jakie oferuje Visual Studio w swoich szablonach. Ja korzystałem z XUnit w projekcie TDD, ale Ty być może masz innego faworyta.  Prawda jest taka, że różnica pomiędzy tymi frameworkami jest niewielka. Chodzi w końcu o oznaczanie, co jest testem, a co nie i każdy framework ma na to swój sposób.

Czasami nie wszystkie technologie wspierają najnowszy .NET Framework, ale w momencie tworzenia tego wpisu nie widzę problemu z .NET CORE 3.2.

Kiedyś też nie każdy framework można było uruchomić w Visual Studio bez dodatkowych rozszerzeń. Teraz to nie jest problem.

Szablony testów w Visual Studio 2019

Niech też Ciebie nie zmylą te 4 szablony. Frameworków tak naprawdę jest tylko 3.

  • MSTest
  • NUnit
  • xUnit

Wybierzmy więc na początku MSTest. Framework ten został stworzony przez sam Microsoft.

Musimy mieć też co testować. Na potrzeby tego wpisu stworzyłem prostą klasę broni.

public class Gun
{
    private int _bullets;
    private int _howmanyBulletsCanGunHave;

    public Gun(int? bullets)
    {
        _howmanyBulletsCanGunHave = bullets;
        _bullets = bullets;
    }

    public bool Fire()
    {
        if (_bullets > 0)
        {
            _bullets = _bullets - 1;
            return true;
        }
        return false;
    }

    public bool HasAmmo()
    {
        return _bullets > 0;
    }

    public void Recharge()
    {
        _bullets = _howmanyBulletsCanGunHave;
    }
}

Chcemy sprawdzić, czy klasa działa poprawnie, to znaczy, czy metoda Fire rzeczywiście zwróci false, gdy skończą mi się naboje. Chce też potwierdzić, że wyskoczy mi wyjątek, gdy zamiast liczby całkowitej podam NULL.

MSTest

W MSTest oznaczamy klasy testowe atrybutem [TestClass]. Przypadki testowe oznaczamy atrybutem [TestMethod]

[TestClass]
public class UnitTest
{
    [TestMethod]
    public void TestIfFireReturnFalseWhenThereIsNoBullets()
    {
        Gun gun = new Gun(1);

        gun.Fire();
        Assert.IsFalse(gun.Fire());
    }

    [TestMethod]
    public void TestWhereThereIsABulltetThenFireReturnsTrue()
    {
        Gun gun = new Gun(1);

        Assert.IsTrue(gun.Fire());
    }
}

Używamy  atrybutu [TestInitialize] do oznaczenia metody, która ustawi zmienne przed wykonaniem jakiekolwiek przypadku testowego.

[TestClenaup] wykona kod po wykonaniu każdego testu. Jest to miejsce na twoje sprzątanie,

private Gun gun;
[TestInitialize]
public void Initialize()
{
    gun = new Gun(1);
}

[TestCleanup]
public void Cleanup()
{
    gun.Recharge();
}

Jak testować wyjątki?

Używasz atrybutu [ExpectedException], aby weryfikować taki test.

[TestMethod]
[ExpectedException(typeof(System.InvalidOperationException))]
public void ShouldThrowNullReferenceExeptionWhenBulletsAreNull()
{
    gun = new Gun(null);

    Assert.IsTrue(gun.Fire());
}

Gdy chcesz stworzyć test, który wykona kilka przypadku dla różnych danych, to używasz atrybutu [DataRow].

[TestMethod]
[DataRow(1, 1)]
[DataRow(7, 7)]
public void TestIfGunCanFireAsManyTimes(int numberOfShots, int bullets)
{
    gun = new Gun(bullets);

    for (int i = 0; i < numberOfShots; i++)
    {
        gun.Fire();
    }

    Assert.IsFalse(gun.Fire());
}

Gdybyś chciał korzystać z dynamicznych danych o to przykład

[DataTestMethod]
[DynamicData(nameof(GetData), DynamicDataSourceType.Method)]
public void TestAddDynamicDataMethod(int a, int b, int expected)
{

}

public static IEnumerable<int[]> GetData()
{
    yield return new int[] { 1, 1, 2 };
    yield return new int[] { 12, 30, 42 };
    yield return new int[] { 14, 1, 15 };
}

Jakby miał powiedzieć, co wyróżnia MSTest, to ilość atrybutów.Teraz analogicznie zobaczmy, jak to jest w NUnit.

NUnit

W NUnit nie musisz oznaczać klasy testowej. Przypadki testowe oznaczasz atrybutem [Test]

public class Tests
{
    [Test]
    public void TestWhereThereIsABulltetThenFireReturnsTrue()
    {
        gun = new Gun(1);
    
        Assert.IsTrue(gun.Fire());
    }

Metodę tworzącą dane testowe dla każdego testu oznaczasz atrybutem [SetUp]

[TearDown] określa metodę, która uruchomi się po zakończonym teście.

private Gun gun;
[SetUp]
public void Initialize()
{
    gun = new Gun(1);
}

[TearDown]
public void Cleanup()
{
    gun.Recharge();
}

NUnit nie ma atrybutu do deklaracji tego, że testujemy wyskoczenie wyjątku w kodzie. 

Oto najbliższy kod, który może spełnić podobny cel.

[Test]
public void ShouldThrowNullReferenceExeptionWhenBulletsAreNull()
{

    Assert.Throws(typeof(InvalidOperationException),
           () =>
           {
               gun = new Gun(null);
           });
}

Do testów data-driven-test używamy atrybutu [TestCase].

[TestCase(1, 1)]
[TestCase(2, 2)]
[TestCase(3, 3)]
public void TestIfGunCanFireAsManyTimes(int numberOfShots, int bullets)
{
    gun = new Gun(bullets);

    for (int i = 0; i < numberOfShots; i++)
    {
        Assert.IsTrue(gun.Fire());
    }

    Assert.IsFalse(gun.Fire());
}

Do danych dynamicznych używasz atrybutu [TestCaseSource].

[TestCaseSource("GetData")]
public void TestAddDynamicDataMethod(int a, int b, int expected)
{

}

public static IEnumerable<int[]> GetData()
{
    yield return new int[] { 1, 1, 2 };
    yield return new int[] { 12, 30, 42 };
    yield return new int[] { 14, 1, 15 };
}

Pora zobaczyć jak radzi sobie mój ulubieniec.

XUnit

Podobnie jak w NUnit nie masz atrybutu oznaczającego, że dana klasa jest klasą testową. Zamiast atrybutów jak [SetUp] masz konstruktor, który wywoła się przy każdym przypadku testowym. Moim zdaniem jest to bardziej intuicyjnie.

Jakbyś chciał wykonać kod, po zakończeniu każdego przypadku testowego to masz trochę problem. W xUnit nie ma do tego atrybutu, a dokumentacja sugeruje skorzystanie z interfejsu IDispose.

public class UnitTest
{
    private Gun gun;
    public UnitTest()
    {
        gun = new Gun(1);
    }

Przypadki testowe oznaczasz atrybutem [Fact]

[Fact]
public void TestWhereThereIsABulltetThenFireReturnsTrue()
{
    gun = new Gun(1);

    Assert.True(gun.Fire());
}

Nie ma atrybutu do oznaczenia testu dla wyjątków. Możesz natomiast napisać taki kod.

[Fact]
public void ShouldThrowNullReferenceExeptionWhenBulletsAreNull()
{

    Assert.Throws(typeof(InvalidOperationException),
           () =>
           {
               gun = new Gun(null);
           });
}

Z atrybutem [Theory] i [InlineData] określasz testy, które są kierowane twoimi parametrami. 

[Theory]
[InlineData(2, 2)]
[InlineData(3, 3)]
public void TestIfGunCanFireAsManyTimes(int numberOfShots, int bullets)
{
    gun = new Gun(bullets);

    for (int i = 0; i < numberOfShots; i++)
    {
        Assert.True(gun.Fire());
    }

    Assert.False(gun.Fire());
}

Co dynamicznych danych w xUnit możesz zrobić to na dwa sposoby. Możesz odwołać się do właściwości, która jest wewnątrz klasy testowej. Używasz do tego atrybutu [MemeberData].

[Theory, MemberData(nameof(DynamicDataAsProperty))]
public void TestAddDynamicDataMethod(int a, int b, int expected)
{

}

public static IEnumerable<object[]> DynamicDataAsProperty
{
    get
    {
        return new []
        {
             new object[] { 1, 1, 2 },
             new object[] { 12, 30, 42 },
             new object[] { 14, 1, 15 }
        };
    }
}

Możesz też stworzyć swoją klasę, która jest kolekcją takich dynamicznych danych.

public class IndexOfData : IEnumerable<object[]>
{
    private readonly List<object[]> _data = new List<object[]>
    {
        new object[] { 1, 1, 2 },
        new object[] { 12, 30, 42 },
        new object[] { 14, 1, 15 }
    };

    public IEnumerator<object[]> GetEnumerator()
    { return _data.GetEnumerator(); }

    IEnumerator IEnumerable.GetEnumerator()
    { return GetEnumerator(); }
}

...i później przy pomocy ClassData odwołuje się do tej klasy w taki sposób.

[Theory, ClassData(typeof(IndexOfData))]
public void TestAddDynamicDataMethod2(int a, int b, int expected)
{

}

Kończymy tutaj to porównanie. O to różnicę w atrybutach pomiędzy tymi frameworkami w pigułce.

NUnitMSTestXUnitKomentarz

[Test]

[TestMethod][Fact]Oznaczenie metody do testu

[TextFixture]

[TestClass] Nie istniejeOznacza klasę testową

[SetUp]

[TestInitialize] KonstruktorWywoływana metoda przy każdym teście

[TearDown]

[TestCleanup]IDisposable.Dispose napisz swój kodWywołany po skończonym teście

[OneTimeTimeSetUp]

[ClassInitialize]IClassFixture<T>Uruchomienie jednej metody przed rozpoczęciem testu

[OneTimeTearDown]

[ClassCleanup]IClassFixture<T>Uruchomienie jednej metody po wykonaniu testu

[Ignore("powód")]

[Ignore][Fact(Skip="powód")Ignoruje przypadek testowy

[Property]

[TestProperty][Trait]Ustawia metadane do testu

[Theory]

[DataRow][Theory]Ustawia data-driven test

[Category("")]

[TestCategory("")][Trait("Category","")]Kategoryzuje klasy testowe lub metody

Jak widzisz, dużych różnic nie ma. 

Gdyby miał Ci jednak polecić, który framework wybrać to bym wybrał xUnit. Oto moje powody:

  • Atrybuty [Fact] i [Theory] można rozszerzyć do własnych celów. 
  • xUnit został stworzony przez jednego z oryginalnych twórców NUnit oraz przez pracownika Microsoftu
  • Brak wielu atrybutów, akurat uczy nas pisać bardziej przejrzyste testy. Serio czy [SetUp] lub [TestInitialize] jest Ci potrzebne, jak masz konstruktor. 
  • Kwestia gustu, ale testy xUnit są czytelniejsze
  • xUnit ma dużo dodatkowych paczek w NuGet 

Wybór należy do Ciebie. Jednakże odradzam wybranie frameworka MSTest. Chociażby przez ten atrybut [ExpectedException], który nawołuje do złych nawyków.