Fluent Assertions – przyjemne asserty w testach

Wprowadzenie

Testy jednostkowe oraz testy integracyjne potrafią bardzo ułatwić wyłapywanie błędów podczas tworzenia aplikacji (w szczególności błędów regresji). Dlatego na ogół bardzo chcemy je dodać do aplikacji, ale niestety często spotykamy się z oporem „góry”, bo pisanie testów zajmuje dużo czasu. Z doświadczenia wiem, że jednym z powodów wydłużania czasu pisania testów są rozbudowane asserty. Dlatego w dzisiejszym wpisie chcę Ci pokazać bibliotekę Fluent Assertions, która umożliwia organizację assertów w bardzo przyjemny sposób.

Testy Logiki Biznesowej

W przykładzie do wpisu przygotowałem dwa proste testy logiki biznesowej. Będziemy testować metodę GetById z klasy ProductLogic.

W swoich projektach staram się, aby klasy logiki zwracały obiekty Result, w których znajduje się informacja, czy operacja się powiodła, czy nie. W przypadku niepowodzenia result jest błędny i właściwość Errors ma informacje o błędzie. Gdy wszystko poszło OK, wtedy we właściwości Value znajdują się poprawne dane. Sama klasa Result wygląda tak:

public class Result
{
public bool Success { get; set; }
public IEnumerable<ErrorMessage> Errors { get; set; }
public static Result Ok()
{
return new Result()
{
Success = true
};
}
public static Result<T> Ok<T>(T value)
{
return new Result<T>()
{
Success = true,
Value = value,
Errors = new List<ErrorMessage>()
};
}
public static Result<T> Failure<T>(IEnumerable<ValidationFailure> validationFailures)
{
var result = new Result<T>();
result.Success = false;
result.Errors = validationFailures.Select(v => new ErrorMessage()
{
PropertyName = v.PropertyName,
Message = v.ErrorMessage
});
return result;
}
public static Result<T> Failure<T>(string message)
{
var result = new Result<T>();
result.Success = false;
result.Errors = new List<ErrorMessage>()
{
new ErrorMessage()
{
PropertyName = string.Empty,
Message = message
}
};
return result;
}
}
public class Result<T> : Result
{
public T Value { get; set; }
}
public class ErrorMessage
{
public string PropertyName { get; set; }
public string Message { get; set; }
}
view raw Result.cs hosted with ❤ by GitHub

Powyższy kod, poza dwiema wersjami klasy Result (zwykłą oraz generyczną), zawiera również kilka pomocniczych metod, które wspierają w szybkim tworzeniu obiektów wyniku.

Sama metoda GetById z ProductLogic wygląda tak:

public class ProductLogic : IProductLogic
{
private Lazy<IProductRepository> _repository;
protected IProductRepository Repository
{
get { return _repository.Value; }
}
public ProductLogic(Lazy<IProductRepository> repository)
{
_repository = repository;
}
public Result<Product> GetById(int id)
{
var product = Repository.GetById(id);
if (product == null)
{
return Result.Failure<Product>($"Nie ma produktu o id {id}.");
}
return Result.Ok(product);
}
}
view raw ProductLogic.cs hosted with ❤ by GitHub

Jest ona bardzo prosta. Za pomocą repozytorium z jakiegoś źródła pobiera dane produktu o przekazanym ID. Gdy repozytorium zwróciło nulla, wtedy oznacza to, że danych nie ma i logika zwraca błędny wynik z komunikatem. W przeciwnym wypadku wynik jest poprawny i w wyniku właściwości Value  znajdować się będą dane produktu.

Interfejs dla repozytorium wygląda tak:

public partial interface IProductRepository : IRepository<Product>
{
}
public interface IRepository<T> where T : BaseModel, new()
{
void Add(T entity);
void Delete(T entity);
void Delete(int id);
T GetById(int id);
IQueryable<T> GetAllActive();
IQueryable<T> GetAll();
void SaveChanges();
}

Pierwsze testy

W przykładzie do testów użyłem biblioteki xUnit oraz Moq do tworzenia atrap (w tym przypadku atrapy dla repozytorium).

Pierwsza wersja testów zawiera asserty z xUnita, tak dla porównania z tym, co będzie później w Fluent Assertions.

public class GetByIdTests : BaseTest
{
protected Mock<IProductRepository> Repository { get; private set; }
protected ProductLogic Create()
{
Repository = new Mock<IProductRepository>();
return new ProductLogic(new Lazy<IProductRepository>(() => Repository.Object));
}
[Fact]
public void Return_Product_From_Repository()
{
var logic = Create();
var product = Builder<Product>.CreateNew().Build();
Repository.Setup(r => r.GetById(It.IsAny<int>()))
.Returns(product);
var result = logic.GetById(10);
Assert.NotNull(result);
Assert.True(result.Success);
Assert.Equal(product, result.Value);
Assert.NotNull(result.Errors);
Assert.Equal(0, result.Errors.Count());
Repository.Verify(r => r.GetById(10), Times.Once());
}
[Fact]
public void Return_Error_When_Product_Not_Exist()
{
var logic = Create();
Repository.Setup(r => r.GetById(It.IsAny<int>()))
.Returns((Product)null);
var result = logic.GetById(10);
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Null(result.Value);
Assert.NotNull(result.Errors);
Assert.Equal(1, result.Errors.Count());
var error = result.Errors.First();
Assert.Equal(string.Empty, error.PropertyName);
Assert.Equal("Nie ma produktu o id 10.", error.Message);
Repository.Verify(r => r.GetById(10), Times.Once());
}
}
view raw GetByIdTests1.cs hosted with ❤ by GitHub

Jak widać, testy nie są jakoś bardzo skomplikowane. Klasa zawiera dwa testy. Pierwszy dla poprawnego wyniku, drugi natomiast dla błędnego. W pierwszej kolejności tworzę obiekt logiki biznesowej (za pomocą metody Create, która tworzy również wymaganego mocka repozytorium). Następnie konfiguruję mocka, aby zwracał to, co chcę w danym teście sprawdzić. Kolejno wywołuję metodę GetById, a na końcu znajdują się asserty.

Widać, że w przypadku korzystania z klasy Result liczba linijek kodu dla assertów jest dość spora. W przypadku testu dla błędnego wyniku asserty zajmują więcej miejsca niż pozostały kod. Dlatego warto się zastanowić, czy nie można tego jakoś uprościć, aby kod był dużo bardziej zwarty. Właśnie to zrobimy dalej w tym wpisie. 🙂

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Fluent Assertions

Fluent Assertions (https://fluentassertions.com) jest biblioteką, która organizuje asserty w trochę inny sposób, niż jest to domyślnie zrobione w takich bibliotekach, jak xUnit, czy nUnit. Jak sama nazwa mówi, korzystamy z fluent api. Sprowadza się to do tego, że na wartości, którą chcemy sprawdzić, wywołujemy metodę Should, a ta zawiera metody, których możemy użyć do zbudowania assertów.

Najlepiej działanie biblioteki zobaczyć na przykładzie. Poniżej znajdują się te same testy, co wyżej. Różnią się one budową assertów.

[Fact]
public void Return_Product_From_Repository2()
{
var logic = Create();
var product = Builder<Product>.CreateNew().Build();
Repository.Setup(r => r.GetById(It.IsAny<int>()))
.Returns(product);
var result = logic.GetById(10);
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.Value.Should().Be(product);
result.Errors.Should().NotBeNull();
result.Errors.Count().Should().Be(0);
Repository.Verify(r => r.GetById(10), Times.Once());
}
[Fact]
public void Return_Error_When_Product_Not_Exist2()
{
var logic = Create();
Repository.Setup(r => r.GetById(It.IsAny<int>()))
.Returns((Product)null);
var result = logic.GetById(10);
result.Should().NotBeNull();
result.Success.Should().BeFalse();
result.Value.Should().BeNull();
result.Errors.Should().NotBeNull();
result.Errors.Count().Should().Be(1);
var error = result.Errors.First();
error.PropertyName.Should().BeEmpty();
error.Message.Should().Be("Nie ma produktu o id 10.");
Repository.Verify(r => r.GetById(10), Times.Once());
}
view raw GetByIdTests2.cs hosted with ❤ by GitHub

Nie wiem, jak dla Ciebie, ale dla mnie taki zapis jest czytelniejszy niż ten przy użyciu statycznych metod z klasy Assert. Dodatkowo Fluent Assertions umożliwia łatwe rozszerzanie listy dostępnych metod, przez co pisanie testów jest dużo łatwiejsze. Jest też sporo bibliotek, które rozszerzają dostępne asserty. Na przykład Fluent Assertions MVC (https://github.com/fluentassertions/fluentassertions.mvc) dodaje asserty ułatwiające testowanie między innymi kontrolerów.

Fluent Assertions – własne asserty

Według mnie najfajniejszą rzeczą w Fluent Assertions jest to, że możemy bardzo łatwo dodać własne metody do sprawdzania swoich klas. Dzięki temu możemy zmniejszyć liczbę kodu dla assertów w powtarzającym się kodzie (tak jak to zrobiono w powyższym przykładzie).

Tym razem zacznę od końca i pokażę, co będę chciał osiągnąć. Poniżej jest kolejna wersja testów. Tym razem wszystkie asserty zamknąłem w jednej linii:

[Fact]
public void Return_Product_From_Repository3()
{
var logic = Create();
var product = Builder<Product>.CreateNew().Build();
Repository.Setup(r => r.GetById(It.IsAny<int>()))
.Returns(product);
var result = logic.GetById(10);
result.Should().BeSuccess(product);
Repository.Verify(r => r.GetById(10), Times.Once());
}
[Fact]
public void Return_Error_When_Product_Not_Exist3()
{
var logic = Create();
Repository.Setup(r => r.GetById(It.IsAny<int>()))
.Returns((Product)null);
var result = logic.GetById(10);
result.Should().BeFailure("Nie ma produktu o id 10.");
Repository.Verify(r => r.GetById(10), Times.Once());
}
view raw GetByIdTests3.cs hosted with ❤ by GitHub

Prawda, że jest dużo lepiej? Szczególnie że obie metody będą wykorzystywane w wielu miejscach w testach logiki biznesowej.

Ok, ale jak to osiągnąć? W pierwszej kolejności tworzymy klasę, w której dodajemy metody, które posłużą nam do testowania obiektów Result (BeSuccess oraz BeFailure). Klasa wygląda tak:

public class ResultAssertions<T> : ReferenceTypeAssertions<Result<T>, ResultAssertions<T>>
{
public ResultAssertions(Result<T> result)
{
Subject = result;
}
protected override string Identifier => "result";
public ResultAssertions<T> BeSuccess(T value, string because = "", params object[] becauseArgs)
{
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject != null)
.FailWith("The result can't be null.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Success)
.FailWith("The Success should be true.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Value.IsSameOrEqualTo(value))
.FailWith("The Value should be the same as expected.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Errors != null)
.FailWith("The Errors can't be null.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Errors.Any() == false)
.FailWith("The Errors can't have any errors.");
return this;
}
public ResultAssertions<T> BeFailure(string property, string message, string because = "", params object[] becauseArgs)
{
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject != null)
.FailWith("The result can't be null.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Success == false)
.FailWith("The Success should be false.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Value == null)
.FailWith("The Value should be null.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Errors != null)
.FailWith("The Errors can't be null.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(Subject.Errors.Any())
.FailWith("The Errors should have errors.");
var error = Subject.Errors.FirstOrDefault(e => e.PropertyName == property);
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(error != null)
.FailWith($"The Errors should contains error for property '{property}'.");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(error.Message == message)
.FailWith($"The Message for property '{property}' should be '{message}'.");
return this;
}
public ResultAssertions<T> BeFailure(string message, string because = "",
params object[] becauseArgs)
{
BeFailure(String.Empty, message, because, becauseArgs);
return this;
}
}
view raw ResultAssertions.cs hosted with ❤ by GitHub

Klasa dziedziczy po ReferenceTypeAssertions i jako parametry generyczne oczekuje typu, dla którego będziemy pisać asserty (w przykładzie generyczna wersja Result), oraz klasy dziedziczącej po ReferenceTypeAssertions (na ogół jest to nasza nowo dodawana klasa). W konstruktorze przekazujemy obiekt, który będziemy sprawdzali.

Same metody przyjmują parametry, jakie są im potrzebne do assertów. Na przykład metoda BeSuccess oczekuje parametru value, który posłuży jej do sprawdzenia, czy właściwość Value z Result jest taka, jakiej oczekujemy. Metody BeFailure oczekują natomiast komunikatu oraz dodatkowo nazwy właściwości, aby sprawdzić, czy w kolekcji z błędami są odpowiednie błędy.

Wszystkie metody przyjmują dwa zalecane parametry because oraz becauseArgs, których później możemy użyć do budowania komunikatu błędu.

W samej metodzie korzystamy ze statycznej właściwości Assertion z klasy Execute. Za pomocą niej dodajemy kolejne warunki, które chcemy sprawdzić. Dla poszczególnych warunków określamy domyślny komunikat błędu, który zostanie zwrócony, gdy dany warunek nie będzie spełniony.

Mając już przygotowaną klasę z metodami, musimy dodać jeszcze metodę Should. Robimy to w dodatkowej klasie w formie extensions method:

public static class ResultExtensions
{
public static ResultAssertions<T> Should<T>(this Result<T> instance)
{
return new ResultAssertions<T>(instance);
}
}
view raw ResultExtensions.cs hosted with ❤ by GitHub

Rozszerzamy generyczną wersję klasy Result. W metodzie Should tworzymy instancje wcześniej dodanej klasy ResultAssertions, do konstruktora której przekazujemy obiekt, na którym wywołujemy metodę.

Przykład

Tradycyjnie na githubie (https://github.com/danielplawgo/FluentAssertionTests) znajduje się przykład do tego wpisu, w którym możesz sam sprawdzić w praktyce działanie biblioteki.

Podsumowanie

Bardzo lubię Fluent Assertions za to, w jaki sposób pisze się asserty. Jest to czytelniejsze niż to, co domyślnie znajduje się w bibliotekach do testów. Dodatkowo bardzo łatwo dodać własne metody, które później ułatwiają tworzenie testów, bez konieczności pisania wielu linijek assertów. A to wszystko możemy wywołać w ten sam sposób, co wbudowane metody (bo już nowych metod statycznych do klasy Assert z xUnita niestety nie dodamy).

Dodatkowo dostępnych jest już sporo rozszerzeń (takich jak wcześniej wspomniane Fluent Assertions MVC), które rozszerzają możliwości biblioteki.

Pamiętaj, że to, co pokazałem w tym wpisie, to wierzchołek tego, co udostępnia Fluent Assertions. Po więcej informacji odsyłam do strony biblioteki – https://fluentassertions.com.

1 thought on “Fluent Assertions – przyjemne asserty w testach

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *