Wprowadzenie do Mockowania w Testach Jednostkowych z użyciem xUnit
Wprowadzenie do Mockowania w Testach Jednostkowych z użyciem xUnit
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.