W dzisiejszym wpisie poruszymy zagadnienie, które początkującym programistom wydaje się dosyć abstrakcyjne i bezsensowne. Mowa tutaj o TDD czyli sposobie na tworzenie oprogramowania, które zorientowane jest w pierwszej kolejności na utworzeniu testu, pod przyszłą funkcjonalność.
Historia TDD
Test-Driven Development został spopularyzowany przez Kenta Becka w latach 90. XX wieku, choć sama idea testowania oprogramowania nie była nowa. Beck w swojej książce Test-Driven Development: By Example (2002) zdefiniował proces TDD jako kluczowy element zwinnego programowania (Agile).
Wcześniejsze próby automatyzacji testów miały miejsce już w latach 60; ale brak odpowiednich narzędzi utrudniał ich szerokie zastosowanie. Dopiero rozwój języków programowania, takich jak Java i Python, oraz narzędzi do testowania, takich jak JUnit czy NUnit, umożliwił efektywne wdrażanie TDD.
Dziś TDD jest powszechnie stosowane w zespołach programistycznych na całym świecie, szczególnie w projektach, które kładą nacisk na jakość i niezawodność kodu.
Clue TDD
Z serii wpisów o testach, wiesz już, że jest to bardzo ważny element tworzenia oprogramowania. Oprócz wzorców dla metod testowych (np. AAA), istnieją także metodyki łączące faktyczny proces wytwarzania oprogramowania z jego testowaniem. Jedną z nich jest TDD: Test Driven Development. Zakłada ona tworzenie testów dla niezaimplementowanej jeszcze logiki, a dopiero później ją samą. Może się to wydawać dosyć dziwnym posunięciem, bo po co testować coś, co jeszcze nie istnieje?

Dzięki stosowaniu podejścia TDD jasno nastawiamy się na rezultat. Sprawdzamy w teście czy logika przyjmując pewne argumenty zachowuje się zgodnie z założeniami. Skupiamy się wtedy na jej tworzeniu i każdą zmianę w ciele, np. tworzonej metody, możemy skonfrontować z istniejącym już testem. Wierz mi lub nie, ale dla wprawionych w tym podejściu, skraca się całkowity czas tworzenia oprogramowania.
Na początku cały proces może być uciążliwy, jednak po pewnym czasie zauważysz, że jesteś wydajniejszy/wydajniejsza, a Twój kod po napisaniu, spełnia jawnie postawione wcześniej wymagania – w postaci testów.
Cykl Red-Green-Refactor
Jednym z kluczowych elementów TDD jest cykl pracy programisty, który nazywa się „Red-Green-Refactor”.
- Red (Czerwony): Na tym etapie piszemy test, który opisuje oczekiwaną funkcjonalność. Test ten z założenia powinien się nie powieść, ponieważ funkcjonalność jeszcze nie istnieje. Czerwony kolor w wynikach testów zazwyczaj oznacza, że testy zakończyły się niepowodzeniem.
- Green (Zielony): Następnie implementujemy minimalną ilość kodu, która pozwala przejść napisany test. Celem jest osiągnięcie zielonego wyniku testu, nawet jeśli kod jest jeszcze daleki od ideału.
- Refactor (Refaktoryzacja): W tym kroku poprawiamy strukturę kodu, dbając o jego czytelność, wydajność i zgodność z zasadami dobrego programowania. Refaktoryzacja odbywa się bez zmieniania zachowania kodu, co gwarantują testy.
Cykl Red-Green-Refactor pomaga programistom pracować iteracyjnie, skupiając się na jednej funkcji naraz i zapewniając, że każdy etap jest testowany. Dzięki temu unika się wprowadzania nieprzewidzianych błędów.
Kilka zasad Test Driven Development
Zanim przejdziemy do przykładu, to muszę wspomnieć o kilku zasadach. Po pierwsze nie pisz testu dla zbyt dużych fragmentów kodu, bo to już nie jest TDD, a masochizm. Ideą tego podejścia jest dostarczanie testów dla małych fragmentów nowej logiki. Mamy wtedy jawnie postawione wymagania funkcjonalne dla naszego kodu i możemy go w łatwy sposób sprawdzać na każdym etapie development-u. Po drugie, zawsze pamiętaj o iteracji „Red-Green-Refactor”. Podziel swoje zadanie na małe bloki, które dają się testować i powtarzaj proces TDD dla każdego z nich, po dostarczeniu logiki. I po trzecie, najważniejsze, nie zniechęcaj się, ponieważ na początku, jak już wspomniałem, podążanie tą metodyką może być uciążliwe, do momentu aż dojdziesz do pewnej wprawy.
Przykład TDD
Wyobraź sobie, że musisz utworzyć serwis do rezerwacji zasobów. Posiadasz już implementację na poziomie DAL, a teraz musisz dostarczyć warstwę logiki. Z treści zadania wiesz, że w przypadku powodzenia musisz zwrócić id nowoutworzonej rezerwacji. W przypadku, kiedy zasób jest zajęty w żądanym przedziale czasowym, konieczne jest zwrócenie wyjątku ze stosowną treścią.
Zajmijmy się najpierw strukturą dla naszego testu. Będę używał biblioteki jest, jednak w większości framework-ów testowych procesy te wyglądają podobnie. Po napisaniu samego testu nie zapomnij go uruchomić (powinien nie przejść).
const reservationRepositoryMock = {
save: jest.fn(),
findByResourceIdAndDatesRange: jest.fn()
};
describe('ReservationsService', () => {
let reservationsService: ReservationsService;
beforeEach(() => {
reservationsService = new ReservationsService(reservationRepositoryMock);
});
test('reservations service is defined', () => {
expect(reservationsService).toBeDefined();
});
describe.skip('create', () => {
it('returns id of the newly created reservation if creation process succeed', async () => {
});
it('throws an error if the resource is busy in selected date range', async () => {
});
})
});Teraz, aby nasz test przechodził musimy utworzyć klasę, którą w teście próbujemy inicjalizować. Ponownie powinniśmy uruchomić test, lecz tym razem powinien on już działać.
export class ReservationsService {
constructor(
private readonly reservationRepository: ReservationRepository
) {}
}Kolejny krok to implementacja pierwszego z naszych testów. Chcemy sprawdzić czy metoda create z klasy serwisowej zwraca id rezerwacji, która została utworzona. Po napisaniu nie zapomnijmy uruchomić zmienionego kodu testującego,
it('returns id of the newly created reservation if creation process succeed', async () => {
jest.spyOn(reservationRepositoryMock, 'findByResourceIdAndDatesRange').mockResolvedValueOnce([]);
const result = await reservationsService.create({
resourceId: faker.datatype.uuid(),
userId: faker.datatype.uuid(),
from: faker.date.past(),
to: faker.date.future()
});
expect(result).toBeInstanceOf(ModelId);
});Teraz, konieczne jest napisanie metody create, która będzie realizowała wymagania akceptacyjne, wyrażone w postaci testu. Zauważ, że pomijamy tutaj drugi z wymagań, dotyczący rzucania wyjątku, w przypadku zajętości zasobu. Zajmiemy się nim w kolejnej iteracji, a tym czasem ponownie uruchommy test.
export class ReservationsService {
constructor(
private readonly reservationRepository: ReservationRepository
) {}
async create(createReservationDTO: CreateReservationDTO): Promise<ModelId> {
const reservation = ReservationEntity.create(createReservationDTO);
await this.reservationRepository.save(reservation);
return reservation.id;
}
}Zaimplementujmy teraz drugi test, który będzie sprawdzał czy w przypadku, gdy resource nie jest dostępny w żądanym zakresie czasowym, nasza metoda informuje nas o tym fakcie stosownym błędem. PS. nie zapomnij o uruchomieniu!
it('throws an error if the resource is busy in selected date range', async () => {
const reservationData = {
resourceId: faker.datatype.uuid(),
userId: faker.datatype.uuid(),
from: faker.date.past(),
to: faker.date.future()
};
jest.spyOn(reservationRepositoryMock, 'findByResourceIdAndDatesRange').mockResolvedValueOnce([
ReservationEntity.create(reservationData)
]);
await expect(reservationsService.create(reservationData))
.rejects
.toThrowError(new Error('Selected resource is not available.'));
});Kontynuujmy naszą iterację i teraz do utworzonego testu dostosujmy istniejącą, w metodzie create, logikę. Dodajmy więc walidację dostępności zasobu i wyrzućmy wyjątek, kiedy nie jest on dostępny. Test powinien już w całości przechodzić.
export class ReservationsService {
constructor(
private readonly reservationRepository: ReservationRepository
) {}
async create(createReservationDTO: CreateReservationDTO): Promise<ModelId> {
await this.validateResourceAvailability(dto.resourceId, {
from: dto.from,
to: dto.to
});
const reservation = ReservationEntity.create(createReservationDTO);
await this.reservationRepository.save(reservation);
return reservation.id;
}
private async validateResourceAvailability(id: string, range: ResourceDatesRange): Promise<void> {
const resourceReservations = await this.reservationRepository.findByResourceIdAndDatesRange(
id,
range,
);
if (resourceReservations.length > 0) {
throw new Error('Selected resource is not available.');
}
}
}Cały nasz test może wyglądać w następujący sposób:
describe('ReservationsService', () => {
let reservationsService: ReservationsService;
beforeEach(() => {
reservationsService = new ReservationsService(reservationRepositoryMock);
});
test('reservations service is defined', () => {
expect(reservationsService).toBeDefined();
});
describe('create', () => {
it('returns id of the newly created reservation if creation process succeed', async () => {
jest.spyOn(reservationRepositoryMock, 'findByResourceIdAndDatesRange').mockResolvedValueOnce([]);
const result = await reservationsService.create(reservationData);
expect(result).toBeInstanceOf(ModelId);
});
it('throws an error if the resource is busy in selected date range', async () => {
jest.spyOn(reservationRepositoryMock, 'findByResourceIdAndDatesRange').mockResolvedValueOnce([
ReservationEntity.create(reservationData)
]);
await expect(reservationsService.create(reservationData))
.rejects
.toThrowError(new Error('Selected resource is not available.'));
});
})
});Zalety i wady TDD
Test-Driven Development to podejście, które oferuje wiele korzyści, ale nie jest pozbawione wyzwań.
Zalety:
- Poprawa jakości kodu: Dzięki pisaniu testów przed implementacją programista musi dokładnie przemyśleć projekt i możliwe scenariusze.
- Większa modularność: Kod pisany zgodnie z TDD jest zazwyczaj bardziej modułowy i łatwiejszy w utrzymaniu.
- Zmniejszenie liczby błędów: Testy wykrywają błędy na wczesnym etapie, co obniża koszty ich naprawy.
- Lepsza dokumentacja: Testy mogą służyć jako żywa dokumentacja, pokazując, jak działa kod.
Wady:
- Większy nakład pracy na początku projektu: Pisanie testów wymaga czasu, co może opóźnić pierwsze wersje produktu.
- Złożoność w dużych projektach: W projektach z istniejącym kodem wdrożenie TDD może być trudne.
- Ryzyko nadmiernego skupienia na testach: Programiści mogą poświęcać zbyt dużo czasu na testy, zaniedbując inne aspekty projektu.
TDD to narzędzie, które najlepiej sprawdza się w projektach, gdzie jakość kodu i długoterminowa utrzymywalność są priorytetami.
Podsumowanie
- TDD jest metodyką tworzenia oprogramowania w oparciu o pisanie testów.
- Test Driven Development zakłada tworzenie testu przed napisaniem funkcjonalności.
- Cały proces składa się z trzech kroków: utworzenie testu, dostarczenie logiki, powtórzenie całości dla nowej jednostki zadaniowej.


Dodaj komentarz