Wprowadzenie do Mockowania w Testach Jednostkowych z użyciem xUnit

Wprowadzenie do Mockowania w Testach Jednostkowych z użyciem xUnit
1 maja 2024 Brak komentarzy Testowanie Tajko

W dzisiejszym wpisie przyjrzymy się jednemu z najważniejszych narzędzi w arsenale każdego testera oprogramowania: mockowaniu w ramach testów jednostkowych przy użyciu xUnit. Mockowanie to technika tworzenia imitacji (mocków) obiektów, które symulują zachowanie prawdziwych obiektów w kontrolowany sposób. Jest to niezwykle przydatne, gdy testowane komponenty zależą od innych obiektów, które mogą być trudne do skonfigurowania, kosztowne w utrzymaniu lub niestabilne.

Podstawy Mockowania

Zacznijmy od najprostszego przykładu, pokazującego, jak można zaimplementować mockowanie w testach jednostkowych przy użyciu biblioteki Moq, która jest popularnym narzędziem współpracującym z xUnit.

Prosty Przykład Mockowania Zależności

[Fact]
public void Test_GetData_ReturnsMockedData()
{
    // Arrange
    // Tworzenie mocka dla interfejsu IRepository
    var mockRepository = new Mock<IRepository>();

    // Konfiguracja mocka, aby metoda GetData zwracała "Mocked Data"
    mockRepository
        .Setup(repo => repo.GetData())
        .Returns("Mocked Data");

    // Inicjalizacja testowanego serwisu z zainjektowanym mockiem
    var service = new DataService(mockRepository.Object);

    // Act
    // Wywołanie metody, która powinna zwrócić zmockowane dane
    var result = service.GetData();

    // Assert
    // Sprawdzenie, czy wynik metody jest zgodny z oczekiwaniami
    Assert.Equal("Mocked Data", result);
}
Zaawansowane Scenariusze Mockowania

Po opanowaniu podstaw, możemy przejść do bardziej skomplikowanych przypadków użycia, takich jak mockowanie wywołań asynchronicznych i obsługa wyjątków.

Mockowanie Metod Asynchronicznych

[Fact]
public async Task Test_GetDataAsync_ReturnsMockedData()
{
    // Arrange
    // Tworzenie mocka dla IRepository
    var mockRepository = new Mock<IRepository>();

    // Konfiguracja, aby metoda GetDataAsync zwracała "Mocked Async Data" asynchronicznie
    mockRepository
        .Setup(repo => repo.GetDataAsync())
        .ReturnsAsync("Mocked Async Data");

    // Utworzenie serwisu z mockiem
    var service = new DataService(mockRepository.Object);

    // Act
    // Asynchroniczne wywołanie metody serwisu
    var result = await service.GetDataAsync();

    // Assert
    // Sprawdzenie, czy wynik jest zgodny z oczekiwaniami
    Assert.Equal("Mocked Async Data", result);
}

Obsługa Wyjątków przy Użyciu Mockowania

[Fact]
public void Test_GetData_ThrowsException()
{
    // Arrange
    // Mockowanie interfejsu IRepository
    var mockRepository = new Mock<IRepository>();

    // Konfiguracja mocka, aby rzucić wyjątek przy wywołaniu GetData
    mockRepository
        .Setup(repo => repo.GetData())
        .Throws(new InvalidOperationException("Nie udało się uzyskać dostępu do danych"));

    // Utworzenie serwisu z mockiem
    var service = new DataService(mockRepository.Object);

    // Act & Assert
    // Testowanie, czy metoda rzuci wyjątek InvalidOperationException
    Assert.Throws<InvalidOperationException>(() => service.GetData());
}
Kompleksowe Scenariusze Testowe

Na koniec zobaczmy, jak można zintegrować mockowanie z bardziej złożonymi scenariuszami testowymi, takimi jak testowanie interakcji między komponentami.

Testowanie Interakcji z Użyciem Callbacków

[Fact]
public void Test_UpdateData_CallsSaveChanges()
{
    // Arrange - przygotowanie mocka repozytorium i serwisu danych

    // Tworzenie mocka dla IRepository
    var mockRepository = new Mock<IRepository>();

    // Inicjalizacja DataService z mockowanym repozytorium
    var service = new DataService(mockRepository.Object);

    // Tworzenie obiektu Customer do aktualizacji
    var customer = new Customer { Id = "1", Name = "Naruto Uzumaki" };

    bool saveCalled = false;        // Zmienna do monitorowania wywołania Save
    bool saveChangesCalled = false; // Zmienna do monitorowania wywołania SaveChanges

    // Konfiguracja mocka repozytorium, aby użyć callbacków do zmiany stanu zmiennych
    mockRepository.Setup(repo => repo.Save(It.IsAny<Customer>()))
                  .Callback<Customer>((cust) => saveCalled = true);
    mockRepository.Setup(repo => repo.SaveChanges())
                  .Callback(() => saveChangesCalled = true);

    // Act
    // Wywołanie metody UpdateData, która powinna wywołać Save i SaveChanges na repozytorium
    service.UpdateData(customer);

    // Assert
    // Weryfikacja, czy metody Save i SaveChanges zostały wywołane
    Assert.True(saveCalled, "Metoda Save powinna zostać wywołana.");
    Assert.True(saveChangesCalled, "Metoda SaveChanges powinna zostać wywołana.");
}
Typy obiektów wykorzystywanych w mockowaniu

Poniżej przyjrzymy się różnym typom obiektów wykorzystywanych w mockowaniu, które pomagają w testach jednostkowych: dummy, fake, stub, mock i spy. Zrozumienie tych typów pozwoli na lepsze zrozumienie ich zastosowania i zastosowanie odpowiedniej metody w zależności od potrzeb testu. Bardziej szczegółowe omówienie będzie w osobnym wpisie. Więcej możecie przeczytać tutaj Rozpoznawanie i Zastosowanie Obiektów Testowych w .NET: Dummy, Fake, Stubs, Mocks i Spies

Dummy Objects

Dummy objects to najprostsze obiekty, które są używane, aby wypełnić parametry metod, które są niezbędne do wykonania, ale które nie są używane w żaden inny sposób.

Przykład użycia:

[Fact]
public void TestMethodDummyObjects()
{
    // Arrange
    // Przygotowanie obiektu dummy i mocka repozytorium
    // Tworzenie mocka dla IRepository
    var mockRepository = new Mock<IRepository>();

    // Tworzenie obiektu dummy, który nie ma realnych danych
    var dummyCustomer = new Customer();

    // Inicjalizacja CustomerService z mockowanym repozytorium
    var service = new CustomerService(mockRepository.Object); 

    // Act - wykonanie metody testowanej
    service.AddCustomer(dummyCustomer);

    // Assert
    // weryfikacja, że metoda Save została wywołana na repozytorium z przekazanym obiektem dummy
    mockRepository.Verify(repo => repo.Save(dummyCustomer), 
        Times.Once(), "Save powinno zostać wywołane z fikcyjnym klientem.");
    
    // Opcjonalnie można sprawdzić, czy zostały zapisane zmiany
    mockRepository.Verify(repo => repo.SaveChanges(), 
        Times.Once(), "SaveChanges powinno zostać wywołane raz po dodaniu klienta.");
}
Fake Objects

Fakes są bardziej złożone niż dummies. Te obiekty mają działające implementacje, ale zazwyczaj są uproszczone wersje prawdziwych implementacji.

Przykład użycia:

[Fact]
public void TestCustomerAdditionFakeObjects()
{
    // Arrange
    // Inicjalizacja fałszywego repozytorium
    var fakeRepository = new FakeRepository();

    // Utworzenie serwisu klienta z fałszywym repozytorium
    var service = new CustomerService(fakeRepository);

    // Tworzenie nowego obiektu klienta
    var newCustomer = new Customer { Id = "123", Name = "Goku" };

    // Act
    // Dodanie klienta do serwisu
    service.AddCustomer(newCustomer);

    // Assert
    // Sprawdzenie, czy klient został dodany poprawnie
    Assert.True(fakeRepository.Exists("123"));
}
Stubs

Stubs dostarczają z góry określone odpowiedzi na wywołania, które są od nich oczekiwane w testach.

Przykład użycia:

[Fact]
public void TestMethodStubs()
{
    // Arrange
    // Utworzenie mocka repozytorium
    var stubRepository = new Mock<IRepository>();

    // Konfiguracja stuba, aby metoda GetData zawsze zwracała "Known Data"
    stubRepository.Setup(repo => repo.GetData()).Returns("Znane dane");

    // Utworzenie serwisu danych z podanym stubem jako zależność
    var service = new DataService(stubRepository.Object);

    // Act
    // Wywołanie metody GetData na serwisie
    var data = service.GetData();

    // Assert
    // Weryfikacja, czy zwrócone dane są zgodne z oczekiwaniami
    Assert.Equal("Znane dane", data);
}
Mocks

Mocks są podobne do stubs, ale są używane do sprawdzenia, czy na obiektach wykonane zostały określone działania.

Przykład użycia:

[Fact]
public void TestMethodMock()
{
    // Arrange
    // Utworzenie mocka repozytorium
    var mockRepository = new Mock<IRepository>();

    // Konfiguracja mocka, aby weryfikować wywołanie metody Save
    mockRepository
        .Setup(repo => repo.Save(It.IsAny<Customer>()))
        .Verifiable("Należy wywołać funkcję zapisywania klienta.");

    // Utworzenie serwisu klienta z podanym mockiem jako zależność
    var service = new CustomerService(mockRepository.Object);

    // Act
    // Wywołanie metody dodającej klienta
    service.AddCustomer(new Customer());

    // Assert
    // Weryfikacja, czy metoda Save została wywołana
    mockRepository.Verify();
}
Spies

Spy pozwala na śledzenie wywołań na obiektach, które mają być monitorowane w teście, przy jednoczesnym zachowaniu ich naturalnego zachowania.

Przykład użycia:

[Fact]
public void TestMethodSpies()
{
    // Arrange
    // Utworzenie mocka listy, która będzie monitorowana
    var mockList = new Mock<IList<string>>();

    // Act
    // Dodanie elementu do zmockowanej listy
    mockList.Object.Add("item");

    // Assert
    // Weryfikacja, czy metoda Add została wywołana dokładnie jeden raz z dowolnym stringiem
    mockList.Verify(l => l.Add(It.IsAny<string>()), Times.Once());
}
Definicje klas itp. użytych w przykładach
/// <summary>
/// Definiuje operacje dostępu do danych.
/// </summary>
public interface IRepository
{
    /// <summary>
    /// Pobiera dane w formie ciągu znaków.
    /// </summary>
    /// <returns>Zwraca dane jako ciąg znaków.</returns>
    string GetData();

    /// <summary>
    /// Zapisuje obiekt klienta.
    /// </summary>
    /// <param name="customer">Obiekt klienta do zapisania.</param>
    void Save(Customer customer);

    /// <summary>
    /// Asynchronicznie pobiera dane w formie ciągu znaków.
    /// </summary>
    /// <returns>Zwraca dane jako ciąg znaków asynchronicznie.</returns>
    Task<string> GetDataAsync();

    /// <summary>
    /// Zapisuje wszystkie zmiany dokonane w repozytorium.
    /// </summary>
    void SaveChanges();
}

/// <summary>
/// Reprezentuje klienta.
/// </summary>
public class Customer
{
    /// <summary>
    /// Pobiera lub ustawia identyfikator klienta.
    /// </summary>
    public string Id { get; set; }

    /// <summary>
    /// Pobiera lub ustawia nazwę klienta.
    /// </summary>
    public string Name { get; set; }
}

/// <summary>
/// Serwis obsługujący operacje na klientach.
/// </summary>
/// <remarks>
/// Inicjalizuje nową instancję klasy CustomerService z określonym repozytorium.
/// </remarks>
/// <param name="repository">Repozytorium do obsługi operacji na danych.</param>
public class CustomerService(IRepository repository)
{
    private readonly IRepository _repository = repository;

    /// <summary>
    /// Dodaje klienta i zapisuje zmiany.
    /// </summary>
    /// <param name="customer">Klient do dodania.</param>
    public void AddCustomer(Customer customer)
    {
        // Zapisuje klienta przy użyciu repozytorium
        _repository.Save(customer);

        // Zapisuje wszystkie zmiany dokonane w repozytorium
        _repository.SaveChanges();
    }
}

/// <summary>
/// Serwis do obsługi danych.
/// </summary>
/// <remarks>
/// Inicjalizuje nową instancję klasy DataService z określonym repozytorium.
/// </remarks>
/// <param name="repository">Repozytorium do zarządzania operacjami na danych.</param>
public class DataService(IRepository repository)
{
    private readonly IRepository _repository = repository;

    /// <summary>
    /// Pobiera dane.
    /// </summary>
    /// <returns>Zwraca dane jako ciąg znaków.</returns>
    public string GetData()
    {
        // Pobiera dane z repozytorium
        return _repository.GetData();
    }

    /// <summary>
    /// Asynchronicznie pobiera dane.
    /// </summary>
    /// <returns>Zwraca dane jako ciąg znaków asynchronicznie.</returns>
    public async Task<string> GetDataAsync()
    {
        // Asynchroniczne pobieranie danych z repozytorium
        return await _repository.GetDataAsync();
    }

    /// <summary>
    /// Aktualizuje dane klienta w repozytorium.
    /// </summary>
    /// <param name="customer">Klient, którego dane mają zostać zaktualizowane.</param>
    public void UpdateData(Customer customer)
    {
        // Zapisuje zmodyfikowanego klienta do repozytorium
        _repository.Save(customer);

        // Zapisuje wszystkie zmiany dokonane w repozytorium
        _repository.SaveChanges();
    }
}

/// <summary>
/// Sztuczna implementacja IRepository, używana do testów.
/// </summary>
public class FakeRepository : IRepository
{
    private readonly List<Customer> customers = [];

    /// <summary>
    /// Dodaje obiekt klienta do wewnętrznej listy.
    /// </summary>
    /// <param name="customer">Obiekt klienta do dodania.</param>
    public void Add(Customer customer)
    {
        // Dodaje klienta do wewnętrznej listy
        customers.Add(customer);
    }

    /// <summary>
    /// Sprawdza, czy klient o podanym identyfikatorze istnieje w liście.
    /// </summary>
    /// <param name="customerId">Identyfikator klienta do sprawdzenia.</param>
    /// <returns>Zwraca true, jeśli klient istnieje, inaczej false.</returns>
    public bool Exists(string customerId)
    {
        // Sprawdza, czy klient istnieje w liście
        return customers.Any(c => c.Id == customerId);
    }

    /// <summary>
    /// Symuluje pobieranie danych.
    /// </summary>
    /// <returns>Zwraca przykładowe dane jako ciąg znaków.</returns>
    public string GetData()
    {
        // Zwraca dane jako ciąg znaków, przykładowo z listy klientów
        return "Dummy Data";
    }

    /// <summary>
    /// Asynchronicznie symuluje pobieranie danych.
    /// </summary>
    /// <returns>Zwraca przykładowe dane jako ciąg znaków asynchronicznie.</returns>
    public Task<string> GetDataAsync()
    {
        // Asynchronicznie zwraca dane jako ciąg znaków
        return Task.FromResult("Dummy Async Data");
    }

    /// <summary>
    /// Zapisuje obiekt klienta w wewnętrznej liście.
    /// </summary>
    /// <param name="customer">Obiekt klienta do zapisania.</param>
    public void Save(Customer customer)
    {
        // Zapisuje klienta do listy jeśli jeszcze nie istnieje
        if (!customers.Any(c => c.Id == customer.Id))
        {
            customers.Add(customer);
        }
    }

    /// <summary>
    /// Symuluje zapisanie wszystkich zmian dokonanych w repozytorium.
    /// </summary>
    public void SaveChanges()
    {
        // Symuluje zapisanie zmian w repozytorium
        // W rzeczywistej implementacji tutaj byłoby np. zapisanie do bazy danych
    }
}
Podsumowanie

Podsumowując, mockowanie jest niezastąpionym narzędziem w arsenale każdego programisty i testera oprogramowania, które pozwala na efektywną izolację testowanego kodu od jego zależności. W tym wpisie przedstawiłem w ogólny sposób różne typy obiektów używanych w mockowaniu: dummy, fake, stub, mock, oraz spy. Każdy z tych typów ma swoje specyficzne zastosowania i jest kluczowy dla różnych scenariuszy testowych:

  • Dummy objects są używane głównie do wypełniania parametrów i nie mają wpływu na wynik testu.
  • Fake objects oferują prostsze, kontrolowane implementacje funkcjonalności, które zazwyczaj są bardziej złożone w realnych systemach.
  • Stubs dostarczają z góry określone odpowiedzi na wywołania metody, umożliwiając testerom skupienie się na testowaniu określonych aspektów aplikacji.
  • Mocks służą do weryfikacji interakcji między komponentami, sprawdzając czy określone metody zostały wywołane.
  • Spies pozwalają na obserwację i weryfikację działania rzeczywistych obiektów, rejestrując ich interakcje bez zakłócania ich naturalnych operacji.

Rozumienie, kiedy i jak używać każdego z tych typów obiektów w testach jednostkowych, jest kluczowe dla efektywnego projektowania i wykonania testów. Poprawnie zaimplementowane testy jednostkowe, wykorzystujące techniki mockowania, zwiększają pewność, że system działa zgodnie z oczekiwaniami, jednocześnie zapewniając elastyczność w refaktoryzacji i rozbudowie kodu. Mockowanie nie tylko pomaga w izolacji błędów, ale także stanowi cenne narzędzie do dokumentowania zachowań systemu.

W przyszłych wpisach możemy dokładniej zbadać każdy z tych typów, dostarczając bardziej złożonych przykładów i najlepszych praktyk ich użycia, co pozwoli na jeszcze głębsze zrozumienie i lepsze wykorzystanie mockowania w różnorodnych scenariuszach testowych.

Tagi
O Autorze
Tajko
Tajko Tajko z tej strony:) Obecnie pracuję we Wrocławiu przy projektach desktopowych. Skoro sam się czegoś nauczyłem to i inni mogliby nauczyć się tego co ja w łopatologiczny sposób:)

ZOSTAW ODPOWIEDŹ