Posty z tej serii:

Jest to trzecia część cyklu na temat systemu komentarzy do bloga. W dwóch poprzednich częściach widzieliśmy projekt rozwiązania oraz jego poszczególne elementy implementacji. W tej części zobaczymy wybrane fragmenty implementacji testów.

Narzędzia

Poniższe przykłady bazują na wersjach:

W procesie wytwarzania oprogramowania istnieje wiele różnych rodzajów testów. Jedną z wielu wartości, jakie daje pisanie testów, jest podniesienie jakości tworzonych funkcjonalności. W tej części skupimy się testach jednostkowych oraz integracyjnych.

Unit Tests

To co jest bardzo fajne w NServiceBus’e to jego bardzo rozbudowane możliwości. Jedną z takich możliwości jest testowanie jednostkowe zaimplementowanych Message Handler'ów przy pomocy NServiceBus.Testing. Zobaczmy przykłady:

[Test]
public async Task Handle_StartAddingComment_SendCreateBranchWithProperData()
{
    // Arrange
    var message = new StartAddingComment { CommentId = this.id };
    var saga = this.GetHandler();
    var context = this.GetContext();

    // Act
    await saga.Handle(message, context).ConfigureAwait(false);

    // Assert
    var sentMessage = this.GetSentMessage<CreateBranch>(context);
    Assert.IsNotNull(sentMessage);
    Assert.True(sentMessage.CommentId == this.id);
}

Rozłóżmy powyższy kod na czynniki pierwsze. Nazwa metody reprezentującej test stworzona jest wg konwencji X_Y_Z gdzie:

  • X oznacza nazwę metody, który jest testowana - Handle
  • Y oznacza scenariusz, jaki jest testowany - StartAddingComment
  • Z oznacza oczekiwany rezultat testu - SendCreateBranchWithProperData

Składając wszystko razem, można wywnioskować, że testowana jest metoda Handle w scenariuszu dodania komentarza, a oczekiwanym rezultatem jest wysłanie message’a CreateBranch z poprawnymi danymi. Ciało metody składa się z trzech sekcji:

  • Arrange - w tej sekcji inicjowane są wszystkie elementy potrzebne do wykonania testu
  • Act - w tej sekcji wywoływana jest metoda, której dotyczy test
  • Assert - w tej sekcji sprawdzany jest rezultat wywołania metody z sekcji Act

Nie ma jednej uniwersalnej metody analizowania testu. Patrząc na całość, widać, że najprostszą sekcją jest sekcja Act, gdzie od razu możemy zobaczyć, co jest testowane. Potem można przejść do sekcji Assert, aby przeanalizować, jaki jest spodziewany rezultat, a na końcu sprawdzić, w jaki sposób tworzone są elementy wymagane do wykonania testu.

Z racji tego, że testów jest zawsze więcej niż jeden, pewne powtarzające się elementy kodu można uwspólnić tak, aby możne je było wykorzystać w pozostałych testach. W przykładzie są trzy takie elementy:

  • var saga = this.GetHandler(); - metoda GetHandler tworzy Message Handler’a, którego metody będą testowane
  • var context = this.GetContext(); - metoda GetContext tworzy context NServiceBus’a tak, aby można było uruchomić test bez konieczności odwoływania się do zewnętrznym źródeł danych np. Queue Transport’u
  • var sentMessage = this.GetSentMessage<CreateBranch>(context); - metoda GetSentMessage zwraca message, który powinien zostać wysłany przez metodę Handle

Patrząc jeszcze raz na całość widać, że oczekiwanym rezultatem jest to, aby metoda Handle wysłała message typu CreateBranch z poprawną wartością CommentId

Zobaczmy implementację poszczególnych pomocniczych metod.

private HandlerCommentSaga GetHandler()
{
    this.configurationManager = Substitute.For<IConfigurationManager>();

    return new HandlerCommentSaga(this.configurationManager)
    {
        Data = new CommentSagaData
        {
            CommentId = this.id,
            UserName = @"test",
            UserEmail = @"[email protected]",
            UserWebsite = @"test.com",
            FileName = @"test.txt",
            Content = @"test comment",
            BranchName = @"testBranch"
        }
    };
}

Ponieważ message handler HandlerCommentSaga posiada zależność do IConfigurationManager, w pierwszej kolejności przy pomocy biblioteki NSubstitute tworzona jest fake’owa instancja konfiguratora tak, aby nie odwoływać się do prawdziwej implementacji. Następnie zwracany jest Saga handler z przykładowymi danymi

private TestableMessageHandlerContext GetContext()
{
    return new TestableMessageHandlerContext();
}

Jest to proste opakowanie klasy TestableMessageHandlerContext, która jest częścią NServiceBus.Testing

private TSentMessage GetSentMessage<TSentMessage>(
    TestableMessageHandlerContext context) where TSentMessage : class
{
    return context.SentMessages[0].Message as TSentMessage;
}

Jest to ponowne opakowanie funkcjonalności udostępnianej przez NServiceBus.Testing, która zwraca message wysłany przez testowaną metodę. Opakowanie jest trochę bardziej złożone, ponieważ używa parametru generycznego tak, aby kod wołający metodę mógł decydować, jakiego typu message’a się spodziewa.

Zobaczmy inny, trochę bardziej złożony, scenariusz testowy.

[TestCase(false, false, CommentResponseStatus.Rejected)]
[TestCase(false, true, CommentResponseStatus.Approved)]
[TestCase(true, false, CommentResponseStatus.NotAddded)]
[TestCase(true, true, CommentResponseStatus.NotAddded)]
public async Task GetCommentResponseStatus_Input_ExpectedResult(
    bool isPullRequestOpen,
    bool isPullRequestMerged,
    CommentResponseStatus expectedResult)
{
    // Arrange
    const string etagResult = "1234";
    
    Func<Task<(bool result, string etag)>> f1 = () => 
        Task.Run(() => (isPullRequestOpen, etagResult));
    
    Func<Task<bool>> f2 = () => 
        Task.Run(() => isPullRequestMerged);
    
    var handler = this.GetHandler();

    // Act
    var result = await handler.GetCommentResponseStatus(f1, f2)
        .ConfigureAwait(false);

    // Assert
    Assert.That(result.ResponseStatus, Is.EqualTo(expectedResult));
    Assert.That(result.ETag, Is.EqualTo(etagResult));
}

W implementacji tego testu został użyty feature biblioteki NUnit o nazwie TestCase-Attribute. Pozwala on w jednej testowej metodzie zakodować wiele scenariuszy. Kod dla każdego scenariusza jest taki sam, a jedyną różnicą są parametry wejściowe oraz oczekiwany rezultat testowanej metody. Inną możliwością jest napisanie czterech osobnych testów dla każdego scenariusza. Patrząc na nazwę metody można wywnioskować, że sprawdzany jest status odpowiedzi na komentarz w zależności od stanu Pull Request'a. Testowana metoda GetCommentResponseStatus przyjmuje dwa parametry:

  • Func<Task<(bool result, string etag)>> - parametr ten jest funkcją zwracającą informację tak lub nie, na pytanie czy Pull Request jest nadal otwarty. Rezultat, jaki ma być zwrócony przez tę funkcję, sterowany jest poprzez parametr metody isPullRequestOpen.

  • Func<Task<bool>> - parametr ten jest również funkcją, która zwraca informację o tym, czy Pull Request został z’merge’owany do gałęzi master. Rezultat, jaki ma być zwrócony przez tę funkcję, sterowany jest prze parametr isPullRequestMerged.

Wynik zwracany przez testowaną metodę GetCommentResponseStatus porównywany jest to oczekiwanego rezultatu, który również jest sterowany przez parametr wejściowy expectedResult. Scenariusze do przetestowania opisane są w atrybutach metody testującej.

Integration Tests

Do pisania testów integracyjnych można wykorzystać tę samą technikę co przy pisaniu testów jednostkowych. Zasadniczą różnicą, w stosunku do testów jednostkowych jest to, że testy integracyjne odwołują się do prawdziwych źródeł danych. W projektach integracyjnych zazwyczaj występuje więcej niż jedno zewnętrzne źródło, z którym trzeba się zintegrować, co sprawia, że bardzo trudno jest przygotować tak dane oraz asercje, aby testy wykonywały się automatycznie. Przykładowo, w tym projekcie takim zewnętrznymi źródłami są GitHub oraz dostawca, za pomocą którego można wysyłać maile. Mimo tego testy integracyjne są bardzo pomocne, jeśli chcemy przetestować jeden konkretny kawałek rozwiązania, przygotowując dane oraz sprawdzając wyniki manualnie. Nie trzeba wtedy uruchamiać całości, a jedynie wybrany fragment. Oczywiście zawsze warto mieć końcowe testy integracyjne tak, aby nawet ręcznie przejść przez cały proces i sprawdzić czy wszystko działa zgodnie z oczekiwaniami. Zobaczmy przykłady:

[Test]
public async Task CreatePullRequest_Execute_ProperResult()
{
    // Arrange
    const string branchName = "c-12";
    var api = this.GetGitHubApi();

    // Act
    var result = await api.CreatePullRequest(
        this.configurationManager.UserAgent,
        this.configurationManager.AuthorizationToken,
        this.configurationManager.RepositoryName,
        branchName,
        "master").ConfigureAwait(false);

    // Assert
    Assert.NotNull(result);
    Console.WriteLine(result);
}

W tym teście testowana jest implementacja metody CreatePullRequest, która tworzy Pull Request'a na GitHub'e. Metoda ta jako rezultat zwraca URL do utworzonego Pull Request'a. Rezultat ten jest sprawdzany w sekcji Assert. Dodatkowo zwrócony rezultat wypisywany jest na standardowe wyjście. Aby rzeczywiście przekonać się czy metoda działa zgodnie z oczekiwaniami, trzeba zalogować się na konto GitHub'a i sprawdzić, czy Pull Request faktycznie został utworzony.

Na koniec zobaczmy, jak za pomocą testu integracyjnego można zasymulować końcowego klienta, który będzie inicjował proces dodawania komentarza.

[Test]
public async Task Post_ForCommentData_NoException()
{
    // Arrange
    HttpClient client = new HttpClient
    {
        BaseAddress = new Uri("http://someTestUrl/")
    };

    client.DefaultRequestHeaders
          .Accept
          .Add(new MediaTypeWithQualityHeaderValue("application/json"));

    var comment = new Comment
    {
        UserName = "testUser",
        UserEmail = "[email protected]",
        UserWebsite = "testUser.com",
        FileName = @"test.txt",
        Content = @"new comment",
    };

    var serializer = new JavaScriptSerializer();
    var json = serializer.Serialize(comment);
    var stringContent = new StringContent(
        json,
        Encoding.UTF8,
        "application/json");

    // Act
    HttpResponseMessage response = 
        await client.PostAsync("comment", stringContent).ConfigureAwait(false);

    // Assert
    try
    {
        response.EnsureSuccessStatusCode();
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex);
        Assert.True(false);
        return;
    }

    Assert.True(true);
}

Tym razem zacznijmy analizę od początku. W sekcji Arrange tworzony jest klient HTTP, za pomocą którego zostanie wysłane żądanie typu POST oraz przykładowy komentarz (aby test zadziałał wymagane są rzeczywiste dane). W sekcji Act wysyłane jest żądanie utworzenia komentarza. W sekcji Assert sprawdzany jest wynik żądania. Każdy inny rezultat niż sukces traktowany jest jako błąd, który wypisywany jest na standardowe wyjście.

Tych parę przykładów pokazuje, w jaki sposób można pisać testy. W następnym poście zobaczymy możliwości wdrażania wytworzonych komponentów.

=