Matteo Manferdini

Wielu programistów uważa, że testy jednostkowe są mylące. Jest to pogarszane przez zaawansowane techniki, których potrzebujesz do testowania klas lub kodu asynchronicznego, jak ten dla żądań sieciowych.

W rzeczywistości, u podstaw testowania jednostkowego, są proste fundamentalne koncepcje. Kiedy już je pojmiesz, testowanie jednostkowe nagle staje się bardziej przystępne.

Contents

  • Testy jednostkowe wyjaśnione w prostych, zrozumiałych terminach
  • Testy jednostkowe pozwalają sprawdzać trudno dostępne przypadki brzegowe i zapobiegać błędom
  • Pisanie testów jednostkowych w Xcode
  • Dobra architektura ułatwia testowanie kodu
  • Traktowanie typów wartości jako czystych funkcji w celu uproszczenia testów jednostkowych
  • Weryfikacja kodu przy użyciu funkcji asercji z frameworka XCTest
  • Mierzenie pokrycia kodu testem w Xcode
  • Dlaczego dążenie do określonego pokrycia kodu jest bezsensowne i co powinieneś robić zamiast tego

Testy jednostkowe wyjaśnione w prostych, zrozumiałych terminach

Kiedy piszesz aplikację, przechodzisz przez cykl pisania kodu, uruchamiania go i sprawdzania, czy działa zgodnie z przeznaczeniem. Ale, gdy twój kod się rozrasta, coraz trudniej jest przetestować całą aplikację ręcznie.

Automatyczne testowanie może wykonać te testy za ciebie. Tak więc, praktycznie rzecz biorąc, testowanie jednostkowe jest kodem, który ma na celu upewnienie się, że kod twojej aplikacji jest poprawny.

Innym powodem, dla którego testowanie jednostkowe może być trudne do zrozumienia, jest fakt, że otacza je wiele pojęć: testowanie w Xcode, architektura aplikacji, podwójne testy, wstrzykiwanie zależności i tak dalej.

Ale, w swoim rdzeniu, idea testowania jednostkowego jest całkiem prosta. Możesz pisać proste testy jednostkowe w zwykłym Swift. Nie potrzebujesz żadnej specjalnej funkcji Xcode lub wyrafinowanej techniki, a nawet możesz je uruchomić wewnątrz Swift Playground.

Spójrzmy na przykład. Poniższa funkcja oblicza czynnik liczby całkowitej, która jest produktem wszystkich dodatnich liczb całkowitych mniejszych lub równych od danych wejściowych.

1
2
3
4
5
6
7

func factorial(of liczba: Int) -> Int {
var result = 1
for factor in 1…number {
result = result * factor
}
return result
}

Aby przetestować tę funkcję ręcznie, podałbyś jej kilka liczb i sprawdził, czy wynik jest taki, jakiego oczekujesz. Tak więc, na przykład, wypróbowałbyś ją na czterech i sprawdziłbyś, że wynik wynosi 24.

Możesz to zautomatyzować, pisząc funkcję Swift, która wykonuje test za Ciebie. Na przykład:

1
2
3
4
5

func testFactorial() {
if factorial(of: 4) != 24 {
print(„The factorial of 3 is wrong”)
}
}

To jest prosty kod Swift, który każdy może zrozumieć. Wszystko, co robi, to sprawdza wyjście funkcji i drukuje komunikat, jeśli nie jest ono poprawne.

To są testy jednostkowe w pigułce. Wszystko inne to dzwonki i gwizdki dodane przez Xcode, aby uprościć testowanie.

Albo przynajmniej tak by było, gdyby kod w naszych aplikacjach był tak prosty, jak funkcja czynnikowa, którą właśnie napisaliśmy. Dlatego właśnie potrzebujesz wsparcia Xcode’a i zaawansowanych technik, takich jak testowanie podwójne.

Niemniej jednak ten pomysł jest pomocny zarówno w przypadku prostych, jak i złożonych testów.

Testy jednostkowe pozwalają sprawdzić trudno dostępne przypadki brzegowe i zapobiegać błędom

Możesz uzyskać kilka korzyści z testowania jednostkowego.

Najbardziej oczywistym jest to, że nie musisz ciągle testować swojego kodu ręcznie. Uciążliwe może być uruchomienie aplikacji i dotarcie do konkretnego miejsca z funkcjonalnością, którą chcesz przetestować.

Ale to nie wszystko. Testy jednostkowe pozwalają również na testowanie przypadków brzegowych, które mogą być trudne do stworzenia w działającej aplikacji. A przypadki brzegowe są miejscem, gdzie znajduje się większość błędów.

Na przykład, co się stanie, jeśli podamy zero lub liczbę ujemną do naszej funkcji factorial(of:)?

1
2
3
4
5

func testFactorialOfZero() {
if factorial(of: 0) != 1 {
print(„The factorial of 0 is wrong”)
}
}

Nasz kod łamie się:

W prawdziwej aplikacji ten kod spowodowałby awarię. Zero jest poza zakresem w pętli for.

Ale nasz kod nie zawodzi z powodu ograniczenia zakresów Swift. Zawodzi on, ponieważ zapomnieliśmy uwzględnić w naszym kodzie liczby całkowite niedodatnie. Z definicji faktorium ujemnej liczby całkowitej nie istnieje, podczas gdy faktorium 0 wynosi 1.

1
2
3
4
5
6
7
8
9
10
11
12
13

func factorial(of number: Int) -> Int? {
if (number < 0) {
return nil
}
if (liczba == 0) {
return 1
}
var result = 1
for factor in 1…number {
result = result * factor
}
return result
}

Możemy teraz ponownie uruchomić nasze testy, a nawet dodać sprawdzenie dla liczb ujemnych.

1
2
3
4
5

func testFactorialOfNegativeInteger() {
if factorial(of: -1) != nil {
print(„The factorial of -1 is wrong”)
}
}

W tym miejscu widać ostatnią i moim zdaniem najważniejszą zaletę testów jednostkowych. Testy, które napisaliśmy wcześniej, upewniają się, że aktualizując naszą funkcję factorial(of:), nie złamiemy jej istniejącego kodu.

Jest to kluczowe w złożonych aplikacjach, gdzie musisz ciągle dodawać, aktualizować i usuwać kod. Testy jednostkowe dają ci pewność, że twoje zmiany nie złamały kodu, który działał dobrze.

Pisanie testów jednostkowych w Xcode

Zrozumiawszy testy jednostkowe, możemy teraz przyjrzeć się funkcjom testowania w Xcode.

Gdy tworzysz nowy projekt Xcode, masz szansę od razu dodać testy jednostkowe. Jako przykładu użyję aplikacji do śledzenia kalorii. Pełny projekt Xcode można znaleźć tutaj.

Gdy zaznaczysz tę opcję, twój szablonowy projekt będzie zawierał już skonfigurowany cel testowy. Zawsze możesz dodać go później, ale ja zazwyczaj domyślnie zaznaczam tę opcję. Nawet jeśli nie zamierzam od razu pisać testów, wszystko będzie skonfigurowane, gdy się na to zdecyduję.

Testowy cel już zaczyna się od kodu szablonu dla naszych testów.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

import XCTest
@testable import Calories
class CaloriesTests: XCTestCase {
override func setUpWithError() throws {
// Umieść tutaj kod konfiguracyjny. Ta metoda jest wywoływana przed wywołaniem każdej metody testowej w klasie.
}
override func tearDownWithError() throws {
// Umieść tutaj kod rozbiórki. Ta metoda jest wywoływana po wywołaniu każdej metody testowej w klasie.
}
func testExample() throws {
// To jest przykład funkcjonalnego przypadku testowego.
// Użyj XCTAssert i powiązanych funkcji, aby sprawdzić, czy Twoje testy dają poprawne wyniki.
}
func testPerformanceExample() throws {
// To jest przykład przypadku testu wydajności.
self.measure {
// Umieść tutaj kod, którego czas chcesz zmierzyć.
}
}
}

  • Linia @testable import Calories daje ci dostęp do wszystkich typów w głównym celu twojego projektu. Kod modułu Swift nie jest dostępny z zewnątrz, chyba że wszystkie typy i metody są jawnie zadeklarowane jako publiczne. Słowo kluczowe @testable pozwala obejść to ograniczenie i uzyskać dostęp nawet do prywatnego kodu.
  • Klasa CaloriesTests reprezentuje przypadek testowy, czyli zgrupowanie powiązanych ze sobą testów. Klasa przypadku testowego musi wywodzić się z XCTestCase.
  • Metoda setUpWithError() pozwala na ustawienie standardowego środowiska dla wszystkich testów w przypadku testowym. Jeśli potrzebujesz posprzątać po swoich testach, używasz tearDownWithError(). Metody te są uruchamiane przed i po każdym teście w przypadku testowym. Nie będziemy ich potrzebować, więc możesz je usunąć.
  • Metoda testExample() jest testem podobnym do tych, które napisaliśmy dla naszego przykładu czynnikowego. Możesz nazywać metody testowe jak chcesz, ale muszą one zaczynać się od przedrostka test, aby Xcode mógł je rozpoznać.
  • Na koniec, metoda testPerformanceExample() pokazuje, jak zmierzyć wydajność Twojego kodu. Testy wydajnościowe są mniej standardowe niż testy jednostkowe i używasz ich tylko dla kodu o kluczowym znaczeniu, który nie musi być szybki. My również nie będziemy używać tej metody.

Wszystkie przypadki testowe i testy względne możesz znaleźć w nawigatorze Test w Xcode.

Możesz dodać nowe przypadki testowe do swojego projektu, po prostu tworząc nowe pliki .swift z kodem takim jak ten powyżej, ale łatwiej jest użyć menu Dodaj na dole Nawigatora Testów.

Dobra architektura ułatwia testowanie kodu

W prawdziwym projekcie zazwyczaj nie ma luźnych funkcji, takich jak w naszym przykładzie czynnikowym. Zamiast tego, masz struktury, wyliczenia i klasy reprezentujące różne części twojej aplikacji.

Zwykle ważne jest, aby zorganizować swój kod zgodnie z wzorcem projektowym, takim jak MVC lub MVVM. Posiadanie dobrze zaprojektowanego kodu nie tylko sprawia, że kod jest dobrze zorganizowany i łatwiejszy do utrzymania. Ułatwia również testowanie jednostkowe.

Pomaga nam to również odpowiedzieć na częste pytanie: który kod powinieneś testować najpierw?

Odpowiedź brzmi: zacznij od swoich typów modeli.

Jest ku temu kilka powodów:

  • Jeśli podążasz za wzorcem MVC lub jedną z jego pochodnych, twoje typy modeli będą zawierały logikę biznesową domeny. Cała Twoja aplikacja zależy od poprawności takiego kodu, więc nawet kilka testów ma znaczący wpływ.
  • Typy modelowe są często strukturami, które są typami wartości. Są one znacznie łatwiejsze do przetestowania niż typy referencyjne (za chwilę zobaczymy dlaczego).
  • Typy referencyjne, czyli klasy, mają zależności i powodują efekty uboczne. Aby napisać dla nich testy jednostkowe, potrzebujesz sobowtórów testowych (dummies, stubs, fakes, spies i mocks) i używasz wyrafinowanych technik.
  • Kontrolery (w terminologii MVC) lub modele widoku (w terminologii MVVM) są często podłączone do zasobów takich jak pamięć dyskowa, baza danych, sieć i czujniki urządzeń. Mogą one również uruchamiać równoległy kod. To sprawia, że testy jednostkowe są trudniejsze.
  • Kod widoku jest tym, który zmienia się najczęściej, produkując kruche testy, które łatwo się łamią. A nikt nie lubi naprawiać zepsutych testów.

Traktowanie typów wartości jako czystych funkcji w celu uproszczenia testów jednostkowych

Więc, dodajmy kilka typów modeli do naszej aplikacji.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

struct FoodItem {
let name: String
let caloriesFor100Grams: Int
let grams: Int
}
struct Meal {
private(set) var items: =
var calories: Int {
var calories = 0
for item in items {
calories += (item.caloriesFor100Grams / 100) * item.grams
}
return calories
}
mutating func add(_ item: FoodItem) {
items.append(item)
}
}

Struktura Meal zawiera pewną prostą logikę do dodawania elementów jedzenia i obliczania całkowitej kaloryczności jedzenia, które zawiera.

Testy jednostkowe mają taką nazwę, ponieważ powinieneś testować swój kod w oddzielnych jednostkach, usuwając wszystkie zależności (testowanie zależności razem jest natomiast nazywane testowaniem integracyjnym). Każda jednostka jest wtedy traktowana jak czarna skrzynka. Podajesz jej jakieś dane wejściowe i testujesz jej wyjście, aby sprawdzić, czy jest tym, czego oczekujesz.

To było łatwe w przypadku naszego przykładu factorial, ponieważ była to czysta funkcja, tj. funkcja, w której wyjście zależy tylko od wejścia, i która nie tworzy żadnego efektu ubocznego.

Ale nie wydaje się, aby tak było w przypadku naszego typu Meal, który zawiera stan.

Wartość zwracana przez właściwość calories computed nie ma żadnych danych wejściowych. Zależy ona jedynie od zawartości tablicy items. Metoda add(_:) natomiast nie zwraca żadnej wartości, a jedynie zmienia wewnętrzny stan posiłku.

Ale struktury są typami wartości i możemy je uznać za czyste funkcje. Ponieważ nie mają one zależności, możesz traktować stan początkowy struktury jako wejście metody, a jej stan po wywołaniu metody jako wyjście.

(Jest to jeden z powodów, dla których powinieneś powstrzymać się od umieszczania typów referencyjnych wewnątrz typów wartości).

Weryfikacja kodu przy użyciu funkcji asercji z frameworka XCTest

Mamy teraz trochę kodu do przetestowania i miejsce w naszym projekcie Xcode, gdzie można pisać testy.

Brakuje nam jeszcze jednego składnika: sposobu na wyrażenie, czy nasz kod jest poprawny, czy nie.

W przykładzie na początku tego artykułu użyłem prostych instrukcji print. To nie jest praktyczne dla prawdziwych testów jednostkowych. Nie chcesz tracić czasu na przeszukiwanie logów, aby zidentyfikować, które testy się nie powiodły. Potrzebujemy sposobu, który wskaże nam bezpośrednio na nieudane testy.

W frameworku XCTest można znaleźć szereg funkcji asercji, dzięki którym Xcode wskaże nam testy, które nie przeszły.

Dzięki nim możemy napisać nasz pierwszy test.

Jak już wspomniałem, testy jednostkowe są pomocne w sprawdzaniu przypadków brzegowych, więc zacznijmy się upewniać, że pusty posiłek nie ma kalorii.

1
2
3
4
5
6

class CaloriesTests: XCTestCase {
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, „Pusty posiłek powinien mieć 0 kalorii”)
}
}

XCTest oferuje ogólną funkcję XCTAssert, która pozwala na sprawdzenie dowolnego warunku. Możesz użyć tego dla każdego testu, który piszesz, ale lepiej jest używać bardziej wyspecjalizowanych funkcji asercji, gdy jest to możliwe, takich jak XCTAssertEqual (powyżej), XCTAssertNil i innych. Te produkują lepsze błędy w Xcode niż XCTAssert.

Możesz uruchomić pojedynczy test klikając na diament obok jego nazwy w rynnie edytora kodu.

Gdy test przejdzie, staje się zielony, podczas gdy nieudane testy są oznaczone na czerwono. Możesz zmienić 0 w teście na dowolną inną liczbę, aby zobaczyć, jak wygląda test zakończony niepowodzeniem.

Możesz uruchomić wszystkie testy w przypadku testowym, klikając diament obok deklaracji klasy. Możesz również użyć nawigatora Test, który widzieliśmy powyżej, aby uruchomić pojedyncze testy lub przypadki testowe.

Często chcesz uruchomić wszystkie testy w projekcie naraz, co możesz szybko zrobić, naciskając cmd+U na klawiaturze lub wybierając opcję Test w menu Produkt w Xcode.

Mierzenie pokrycia kodu testem w Xcode

Kolejne, najczęstsze pytanie dotyczące testów jednostkowych brzmi: jak dużą część kodu powinieneś przetestować?

Zazwyczaj mierzy się to pokryciem kodu, tj, procentu kodu objętego przez twoje testy. Zanim więc odpowiemy na to pytanie, zobaczmy, jak można zmierzyć pokrycie kodu testami w Xcode.

Po pierwsze, musisz pozwolić Xcode zebrać pokrycie dla twojego celu testowego. Kliknij przycisk z nazwą projektu w kontrolce segmentowej na pasku narzędzi Xcode (obok przycisku zatrzymania). Następnie wybierz Edytuj schemat… w wyskakującym menu.

Tam wybierz Test w konspekcie, a następnie przejdź do zakładki Opcje. I na koniec wybierz opcję Gather coverage for. Możesz pozostawić jego opcję na wszystkie cele.

Możesz teraz ponownie uruchomić swoje testy, co pozwoli Xcode zebrać dane o pokryciu. Wynik znajdziesz w Nawigatorze raportów, wybierając pozycję Pokrycie kodu w konspekcie.

W tym miejscu możesz sprawdzić procentowy udział kodu objętego testami dla całego projektu i każdego pliku.

Dla naszego przykładu liczby te nie są tak znaczące, ponieważ prawie nie napisaliśmy żadnego kodu. Widzimy jednak, że metoda add(_:) typu Meal ma pokrycie 0%, co oznacza, że jeszcze jej nie testowaliśmy.

Właściwość calories computed ma za to pokrycie 85,7%, co oznacza, że w naszym kodzie są pewne ścieżki wykonania, których nasz test nie uruchomił.

W naszym prostym przykładzie łatwo zrozumieć, jaka to ścieżka. Przetestowaliśmy tylko kalorie pustego posiłku, więc kod w pętli for nie został uruchomiony.

W bardziej zaawansowanych metodach może to jednak nie być tak proste.

Do tego celu można wywołać pasek pokrycia kodu w edytorze Xcode. Można to znaleźć w menu Dostosuj opcje edytora w prawym górnym rogu.

W ten sposób zostanie wyświetlony pasek, który pokazuje pokrycie dla każdej linii kodu.

Liczby mówią, ile razy każda linia została wykonana podczas testów. Na czerwono, możesz zobaczyć, które linie nie zostały w ogóle uruchomione (pełne) lub zostały wykonane tylko częściowo (prążkowane).

Dlaczego dążenie do określonego pokrycia kodu jest bezsensowne i co powinieneś zrobić zamiast tego

Więc, jaki procent jest dobrym procentem pokrycia kodu?

Opinie są bardzo różne. Niektórzy programiści uważają, że 20% jest wystarczające. Inni wybierają wyższe liczby, takie jak 70% lub 80%. Są nawet programiści, którzy uważają, że tylko 100% jest akceptowalne.

W mojej opinii, pokrycie kodu jest bezsensowną metryką. Możesz go używać do informowania o swoich decyzjach dotyczących testowania, ale nie powinieneś traktować go jako celu, który musisz osiągnąć.

Aby zobaczyć dlaczego, napiszmy kolejny test, aby pokryć kod, którego jeszcze nie testowaliśmy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

class CaloriesTests: XCTestCase {
let banana = FoodItem(nazwa: „Banan”, caloriesFor100Grams: 89, grams: 118)
let steak = FoodItem(nazwa: „Stek”, caloriesFor100Grams: 271, grams: 225)
let goatCheese = FoodItem(name: „Goat Cheese”, caloriesFor100Grams: 364, grams: 28)
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, „Pusty posiłek powinien mieć 0 kalorii”)
}
func testCalories() {
var meal = Meal()
meal.add(banan)
meal.add(stek)
meal.add(kozi ser)
XCTAssertEqual(meal.items.count, 3)
XCTAssertEqual(meal.calories, 534)
}
}

Jeśli ponownie uruchomisz wszystkie testy, otrzymasz 100% pokrycie dla pliku Model.swift. Wygląda dobrze, prawda?

Teraz przejdź i usuń metodę testEmptyMeal(). Jeśli uruchomisz pozostałe testy samodzielnie, zobaczysz, że pokrycie dla naszych typów Meal nadal wynosi 100%.

To już pokazuje ci, że liczba 100% może dać ci fałszywe poczucie bezpieczeństwa. Wiesz, że teraz nie testujemy wszystkich przypadków brzegowych dla właściwości calories computed. A jednak nasze pokrycie testowe tego nie odzwierciedla.

Dodam, że 100% pokrycie jest nie tylko mylące, ale nawet szkodliwe. Niektóre kody w twoim projekcie są podatne na ciągłe zmiany, zwłaszcza najnowszy kod, a zwłaszcza w widokach.

Okrycie 100% kodu oznacza, że będziesz musiał tylko produkować kruche testy, które ciągle się psują i które musisz naprawiać. Te testy nie wykrywają błędów. Łamią się, ponieważ zmienia się funkcjonalność twojego kodu.

I tu dochodzimy do punktu, o którym nikt nie mówi.

Pisanie testów nie jest zabawą, pomimo tego, co twierdzą niektórzy programiści. Poprawianie testów jest jeszcze mniej zabawne. Rób to zbyt często, a znienawidzisz testy jednostkowe i odsuniesz je na bok.

Psychologia jest tak samo istotna dla rozwoju oprogramowania jak najlepsze praktyki. Nie jesteśmy maszynami.

Więc, do jakiej liczby powinieneś dążyć? To wymaga szerszej dyskusji, ale tutaj przedstawię wam podsumowanie mojej opinii.

Pisz użyteczne testy, zaczynając od najstarszego kodu w twoim projekcie, który nie będzie się zmieniał. Użyj pokrycia kodu tylko do określenia, jaki kod nie jest jeszcze przetestowany, ale nie używaj go jako celu lub do podjęcia decyzji, kiedy napisać więcej testów. Pokrycie 20% jest lepsze niż 80%, jeśli twoje testy są sensowne i testujesz właściwy kod.

Jak wspomniałem powyżej, typy modeli zawierają najbardziej krytyczny kod w twoim projekcie, który również ma tendencję do najmniejszych zmian. Tak więc, zacznij od tego.

Zazwyczaj nie zawracam sobie głowy testowaniem każdej pojedynczej ścieżki wykonania. Na przykład, w prawdziwym projekcie, nie napisałbym metody testEmptyMeal(), którą pokazałem ci powyżej. Tak, ważne jest, aby testować przypadki brzegowe, ale nie są one ani trochę ważne.

Zwykle napisałbym tylko test testCalories(). To mówi mi, że mój kod działa, i ostrzeże mnie, jeśli później popełnię błąd przy zmianie tej metody.

Z pewnością nie obejmuje on każdej ścieżki, ale ten kod jest tak prosty, że to tylko strata czasu. Spędzanie czasu na pisaniu prawdziwego kodu dla funkcji, które pomagają użytkownikom, jest ważniejsze niż testowanie każdej ścieżki wykonania.

Wiem, że dostanę trochę flack od niektórych deweloperów za tę opinię, ale nie obchodzi mnie to. Pokrycie testowe jest jedną z tych debat, które nigdy się nie skończą.

Kod, który piszesz, jest zazwyczaj w większości poprawny. Nie ma potrzeby popadać w paranoję. Posiadanie mnóstwa testów, które psują się za każdym razem, gdy coś zmieniasz, jest zobowiązaniem, a nie atutem.

Twój czas i siła woli są ograniczone. Poświęć je na ważniejsze zadania.

Architektura aplikacji SwiftUI z MVC i MVVM

Łatwo jest stworzyć aplikację, rzucając trochę kodu razem. Ale bez najlepszych praktyk i solidnej architektury, szybko skończysz z niemożliwym do opanowania kodem spaghetti. W tym przewodniku pokażę ci, jak prawidłowo budować aplikacje SwiftUI.

POBIERZ BEZPŁATNĄ KSIĄŻKĘ TERAZ

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.