W tej serii wpisów zbudujemy wspólnie nieskomplikowaną aplikację bloga od podstaw pisząc do niej testy automatyczne przy użyciu techniki TDD, czyli Test Driven Development. Technika TDD polega na tym, że najpierw piszemy dla danej funkcjonalności test automatyczny, a dopiero potem piszemy kod który tę funkcjonalność implementuje.
Stworzymy wspólnie aplikację bloga, w której napiszemy testy i implementację dla następujących funkcjonalności:
- na liście wpisów wyświetlać się będą tylko wpisy oznaczone jako opublikowane
- zalogowani użytkownicy mogą dodawać wpisy
- tytuł wpisu musi być unikatowy, zawartość wpisu może być pusta, ale jeśli ma wartość to musi mieć minimum 3 znaki
- zalogowani użytkownicy mogą edytować wpisy
- zalogowani użytkownicy mogą usuwać wpisy
Na koniec przerobimy ostatnie ostatnie dwa podpunkty w ten sposób, żeby użytkownicy mogli edytować tylko swoje wpisy, natomiast usuwać wpisy będzie mógł tylko administrator.
W tej części zaczniemy od zainstalowania nowej aplikacji postawionej na Laravelu. Następnie przejdziemy przez krótkie wprowadzenie do tego czym jest i jak używa się narzędzia do testowania PHPUnit. Następnie zastanowimy się jaki powinien być pierwszy test w naszej aplikacji. Napiszemy test sprawdzający wybraną funkcjonalność, po czym napiszemy kod który ją zaimplementuje.
Kod aplikacji znajduje się na GitHubie.
Wymagania
Ten tutorial zakłada podstawową znajomość terminala, composera oraz samego Laravela w takich zakresach jak instalacja frameworka, tworzenie migracji tabeli, obsługa Eloquenta, definiowanie ścieżek, użycie kontrolerów, obsługa i walidacja formularzy oraz użycie silnika Blade. Innymi słowy, jeśli masz już za sobą chociaż jedną małą aplikację postawioną na Laravelu to dasz radę. Nowe i bardziej skomplikowane zagadnienia będę po kolei tłumaczył.
Instalacja
Zacznijmy od zainstalowania świeżej aplikacji za pomocą instalatora Laravela. Przejdźmy w terminalu do wybranego katalogu i wpiszmy:
laravel new posts
Następnie przejdźmy do nowo utworzonego katalogu i zainicjalizujmy repozytorium git:
cd posts
git init
Dobrą praktyką jest od razu dodanie całego kodu jako początkowego commita. Ten pierwszy commit to świeża, można powiedzieć pusta instalacja Laravela, w której jeszcze nic nie zmieniliśmy. Każdy kolejny commit będzie już zawierał zmiany związane stricte z naszą aplikacją. Dzięki temu w historii repozytorium będziemy wyraźnie widzieli, w których plikach dokonaliśmy zmian względem tej "pustej" instalacji Laravela.
git add .
git commit -m "Świeża instalacja Laravel 6.4.1"
Korzyści z pisania testów automatycznych
Zacznijmy od wyjaśnienia jednej kwestii: nawet jeśli w życiu nie słyszałeś o PHPUnit, to nie zmienia faktu, że testujesz swój kod. Codziennie programując wykonujesz dziesiątki albo i setki testów. Piszesz kawałek kodu, przełączasz się na przeglądarkę, odświeżasz, uzupełniasz pola formularza, a potem sprawdzasz w bazie danych czy wszystko się zgadza. Brzmi znajomo? To właśnie testowanie.
To co możesz zrobić za pomocą PHPUnit to zautomatyzować ten proces testowania. Dlatego właśnie mówi się o testach automatycznych. Jakie są korzyści pisania testów automatycznych? Po pierwsze, skraca to czas programowania. Owszem, najpierw trochę go wydłuża, bo pisać testy trzeba się nauczyć. Następnie trzeba je rzeczywiście pisać. Ale gdy mamy już napisany automatyczny test, sam proces przetestowania kodu implementującego daną funkcjonalność zajmie kilkaset milisekund. Nie musimy przełączać się na przeglądarkę, odpalać phpMyAdmin itd.
Po drugie, co ważniejsze, mając testy automatyczne możemy z dużą pewnością siebie wprowadzać zmiany w kodzie. Gdy dodajemy do istniejącej aplikacji nową funkcjonalność to zawsze jest ryzyko, że zepsujemy coś co już dobrze działa. Jeśli testujemy ręcznie możemy o czymś zapomnieć. Jeśli mamy testy automatyczne to po wprowadzeniu zmian po prostu uruchamiamy testy dla wcześniej napisanych funkcjonalności i jeśli testy przeszły to mamy pewność, że nic nie zepsuliśmy.
Po trzecie, testy stają się niejako dokumentacją naszego kodu. Często gdy wracamy do własnego kodu napisanego pół roku temu nie pamiętamy co robi jakiś kawałek kodu. Może jest tam jakiś if, który obsługuje jakiś dziwny warunek brzegowy (ang. edge-case). Jeśli poświęcimy czas na napisanie testu dla tego warunku brzegowego, będziemy mieli miejsce do którego będziemy mogli zajrzeć żeby przypomnieć sobie po co ten if tam jest. Argument, mówiący o tym że testy są dokumentacją kodu jest tym bardziej istotny gdy pracujemy w zespole.
Mógłbym dalej przytaczać korzyści, ale mam nadzieję, że już Was przekonałem.
Krótkie wprowadzenie do testowania za pomocą PHPUnit
W porządku, chcemy zacząć pisać testy automatyczne do naszego kodu. Jak to robimy? Standardem w świecie PHP jest narzędzie PHPUnit
. Jest to framework, który pozwala nam pisać testy automatyczne oraz program (narzędzie konsolowe) do uruchamiania tych testów. PHPUnit to nic innego jak paczka PHP. Możemy ją zainstalować w dowolnym projekcie za pomocą composera (komenda composer require phpunit/phpunit
). Następnie piszemy testy dziedzicząc po klasie dostarczonej przez paczkę oraz uruchamiamy testy za pomocą programu phpunit
który paczka udostępnia.
Laravel ma wbudowane wsparcie dla PHPUnit. W pliku composer.json
znajdziemy w polu require-dev
paczkę phpunit/phpunit
. W głównym katalogu aplikacji znajduje się plik phpunit.xml
, w którym trzymane są ustawienia dla PHPUnit. Mamy też katalog tests
, który jest miejscem na nasze testy. Zobaczmy przykładowo plik tests/Unit/ExampleTest.php
:
// tests/Unit/ExampleTest.php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$this->assertTrue(true);
}
}
Jak widzimy test automatyczny to nic innego jak zwykła klasa PHP. Dziedziczy po klasie Tests\TestCase
, która dziedziczy po klasie Illuminate\Foundation\Testing\TestCase
. Ta klasa z kolei dziedziczy po klasie PHPUnit\Framework\TestCase
. Kluczowa jest właśnie ta ostatnia, to dzięki niej program phpunit
będzie wiedział, że ma rozpatrywać metody w niej zawarte jako testy. Dwie pierwsze to nakładki Laravela na tę klasę, które ułatwiają testowanie aplikacji postawionych na frameworku.
Każda metoda w tej klasie zaczynająca się od słowa test
będzie traktowana jako test, który program phpunit
ma wykonać. Jak uruchomić program phpunit
? Wystarczy będąc w głównym katalogu aplikacji wpisać w terminal:
vendor/bin/phpunit
Powinniśmy zobaczyć w terminalu coś takiego:
Program uruchomił wszystkie testy z katalogu tests
. W świeżej aplikacji Laravela mamy dwie przykładowe klasy z testami. Wspomnianą wcześniej tests/Unit/ExampleTest.php
oraz tests/Feature/ExampleTest.php
. Każda z nich zawiera po jednym teście. W wyniku komendy phpunit
widzimy tekst OK (2 tests, 2 assertions)
, który oznacza tyle, że zostały uruchomione dwa testy. Oba wykonały się poprawie, innymi słowy przeszły.
Poświęćmy chwilę na przeanalizowanie kodu w pliku tests/Unit/ExampleTest.php
. Widzimy tam linijkę:
$this->assertTrue(true);
Częścią każdego testu jest asercja. W dużym skrócie, asercje służą do sprawdzenia zwracanej wartości. To właśnie asercja decyduje o tym, czy test przeszedł. Dziedzicząc po klasach TestCase
mamy do dyspozycji mnóstwo asercji. W tym przykładzie używamy metody assertTrue()
, która sprawdza czy podana jako parametr wartość jest prawdziwa. Zamieńmy linijkę na:
$this->assertTrue(false);
Gdy uruchomimy ponownie testy za pomocą komendy vendor/bin/phpunit
powinniśmy zobaczyć w terminalu coś takiego:
Widzimy na czerwonym tle napis: Tests: 2, Assertions: 2, Failures: 1.
. Zostały wykonane dwa testy zawierające dwie asercje. Wartość false
nie jest oczywiście prawdziwa, w związku z czym test znajdujący się w pliku tests/Unit/ExampleTest.php
nie przeszedł.
Teraz przeanalizujmy zawartość pliku tests/Feature/ExampleTest.php
:
// tests/Feature/ExampleTest.php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
W metodzie testBasicTest()
używamy metody $this->get('/')
i przypisujemy jej wartość do zmiennej $response
. Metoda get()
symuluje wykonanie żądania HTTP GET pod adres wskazany jako pierwszy parametr, w tym przypadku /
czyli strona główna naszej aplikacji. Pod zmienną $response
znajdzie się obiekt klasy TestResponse
, na którym możemy następnie wykonać assercje. W tym przypadku używamy metody assertStatus(200)
, żeby sprawdzić czy aplikacja zwróciła odpowiedź o statusie HTTP równym 200. To może brzmieć nieco skomplikowanie, ale obiecuję, że temat rozjaśni się gdy zaczniemy pisać własne testy.
Pierwszy test naszej aplikacji
Gdy chcemy tworzyć aplikacje za pomocą techniki TDD najtrudniej jest zdecydować od czego zacząć. Jaki powinien być pierwszy test? W naszej aplikacji będzie kilka mniejszych i większych funkcjonalności. Chcemy by na liście wpisów były widoczne tylko te oznaczone jako opublikowane. Oprócz tego w aplikacji musi się znaleźć formularz dodawania i edytowania wpisu. Do tych formularzy będziemy chcieli dodać walidację. Musi się również znaleźć kod, który będzie blokował użytkownikom możliwość edytowania nie swoich wpisów. Administrator ma za to mieć możliwość usuwania dowolnego wpisu.
Warto zastanowić się jaka jest najbardziej kluczowa, podstawowa funkcjonalność naszej aplikacji. Wydaje mi się, że w aplikacji bloga, będzie to wyświetlanie pojedynczego wpisu. Od tego zatem zacznijmy. W celu ułatwienia wejścia w temat, na razie pominiemy sprawdzenie czy wpis został oznaczony jako opublikowany. Napiszemy test funkcjonalny (ang. feature test), który sprawdzi że po wejściu na adres /posts/1
widzimy na stronie tytuł wpisu o id
równym 1.
Stwórzmy w katalogu tests/Feature
plik PostsTest.php
:
// tests/Feature/PostsTest.php
namespace Tests\Feature;
use Tests\TestCase;
class PostsTest extends TestCase
{
}
Następnie rozpiszmy sobie co ma się znaleźć w naszym pierwszym teście:
// tests/Feature/PostsTest.php
namespace Tests\Feature;
use Tests\TestCase;
class PostsTest extends TestCase
{
/** @test */
public function the_posts_show_route_can_be_accessed()
{
// Arrange
// Dodajemy do bazy danych wpis
// Act
// Wykonujemy zapytanie pod adres wpisu
// Assert
// Sprawdzamy że w odpowiedzi znajduje się tytuł wpisu
}
}
Kod w teście powinien zazwyczaj zawierać trzy sekcje. W pierwszej sekcji "Arrange" przygotowujemy to co będzie nam potrzebne w systemie, żeby móc wykonać kod w następnych sekcjach. Innymi słowy przygotowujemy sobie system, który będzie testowany. W tym teście będziemy chcieli sprawdzić, że można odwiedzić stronę ze wpisem. Dlatego w sekcji "Arrange" dodamy wpis do bazy danych. W drugiej sekcji "Act" wykonujemy akcję. W naszym teście będzie to wykonanie zapytania HTTP GET pod adres wpisu dodanego w sekcji "Arrange". W ostatniej sekcji "Assert" wykonujemy asercje, czyli sprawdzenia. W naszym teście sprawdzimy, że w odpowiedzi na zapytanie HTTP wykonane w sekcji "Act" znajduje się tytuł wpisu dodanego wcześniej w sekcji "Arrange".
Pisałem wcześniej, że program phpunit
traktuje jako testy metody poprzedzone słowem "test". Ja osobiście lubię nazywać testy jak najbardziej "po ludzku", dlatego dodawanie tego prefixu "test" nie zawsze jest mi po drodze. Na szczęście inną możliwością przekazania programowi phpunit
, że ma traktować metodę jako test jest poprzedzenie jej adnotacją @test
.
W porządku, napiszmy kod testu:
// tests/Feature/PostsTest.php
namespace Tests\Feature;
use App\Post;
use Tests\TestCase;
class PostsTest extends TestCase
{
/** @test */
public function the_posts_show_route_can_be_accessed()
{
// Arrange
// Dodajmy do bazy danych wpis
$post = Post::create([
'title' => 'Wrabiał krowę w morderstwo cioci',
]);
// Act
// Wykonajmy zapytanie pod adres wpisu
$response = $this->get('/posts/' . $post->id);
// Assert
// Sprawdźmy że w odpowiedzi znajduje się tytuł wpisu
$response->assertStatus(200)
->assertSeeText('Wrabiał krowę w morderstwo cioci');
}
}
W pierwszej sekcji używamy Eloquenta, żeby dodać wpis o konkretnym tytule do bazy danych. Może to wydawać się dziwne, bo przecież nie stworzyliśmy jeszcze modelu Post
ani migracji tabeli posts
. Pamiętajmy jednak, że stosujemy technikę TDD, czyli najpierw test, a dopiero potem implementacja. W drugiej sekcji wykonujemy zapytanie HTTP GET pod adres '/posts' . $post->id
i zapisujemy odpowiedź do zmiennej $response
. Ponownie, nie mamy jeszcze w aplikacji ścieżki, która obsługuje ten adres. Zaraz ją dodamy. W trzeciej sekcji wykonujemy sprawdzenia. Najpierw używamy asercji assertStatus()
, żeby sprawdzić czy zapytanie HTTP zwróciło status 200, to znaczy czy wykonało się poprawnie. Następnie używamy asercji assertSeeText()
, która sprawdzi czy w treści odpowiedzi na wykonane zapytanie HTTP znajduje się tytuł naszego wpisu.
Uruchommy nasz test. Możemy podać programowi phpunit
ścieżkę do konkretnego pliku, dzięki temu wykonają się tylko testy z tego pliku:
vendor/bin/phpunit tests/Feature/PostsTest.php
Powinniśmy zobaczyć coś takiego:
Composer nie był w stanie załadować pliku modelu, którego oczekiwał pod ścieżką app/Post.php
. Wygenerujemy zatem nasz model:
php artisan make:model Post
I uruchommy test ponownie. Jak można było się spodziewać, test nadal nie przeszedł. Ale pojawił nam się nowy błąd. To dobry znak, oznacza że idziemy do przodu. Test wyrzucił wyjątek:
Illuminate\Database\Eloquent\MassAssignmentException: Add [title] to fillable property to allow mass assignment on [App\Post].
Nie dodaliśmy pola title
do pola fillable
w naszym modelu i wyrzuciło nam wyjątek MassAssignmentException
. Poprawmy to w modelu Post
:
// app/Post.php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['title'];
}
Uruchommy test ponownie. Powinniśmy zobaczyć następujący błąd:
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 no such table: posts
To by się zgadzało, stworzyliśmy plik modelu, ale nie stworzyliśmy migracji tabeli posts
. Wygenerujmy ją za pomocą artisana
:
php artisan make:migration create_posts_table --create=posts
Teraz mamy już plik migracji tabeli posts
i jeśli wykonamy komendę php artisan migrate
powinniśmy móc uruchomić testy i zobaczyć nowy błąd:
Illuminate\Database\QueryException: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'title' in 'field list'
Stworzyliśmy plik migracji, ale nie dodaliśmy kolumny title
. Poprawmy to, dodajmy w pliku migracji tabeli posts
linijkę: $table->string('title');
i wykonajmy komendę php artisan migrate:fresh
, żeby utworzyć na nowo tabele w bazie danych. Następnie uruchommy ponownie test. Powinniśmy zobaczyć nowy błąd:
Expected status code 200 but received 404.
Wygląda na to, że udało nam się dodać wpis do bazy danych. Czyli błędy związane z sekcją "Arrange" mamy już za sobą. Teraz pojawił nam się błąd 404. W sekcji "Act" wykonujemy zapytanie GET pod adres wpisu, a następnie w sekcji "Assert" sprawdzamy czy zapytanie zwróciło status 200. Sprawdzenie nie przechodzi, bo nie zdefiniowaliśmy jeszcze ścieżki. Poprawmy to w pliku routes/web.php
:
// routes/web.php
Route::get('/posts/{post}', 'PostController@show');
oraz dodajmy kontroler:
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Post;
class PostController extends Controller
{
public function show(Post $post)
{
}
}
Gdy uruchomimy testy powinniśmy zobaczyć nowy błąd:
Failed asserting that '' contains "Wrabiał krowę w morderstwo cioci".
Wygląda na to, że doszliśmy do ostatniej asercji. Test zwraca nam informację, że pusty string ''
nie zawiera spodziewanego tytułu wpisu. Ma to sens, gdyż nie zwracamy nic w metodzie show()
kontrolera. Zwróćmy w niej widok:
public function show(Post $post)
{
return view('posts.show');
}
Po uruchomieniu testów dostajemy następujący błąd:
Expected status code 200 but received 500.
Serwer zwrócił błąd 500. Powodów dla błędu 500 może być wiele. W naszym przypadku powodem jest brak pliku widoku posts/show.blade.php
. Dlaczego test nam tego nie podpowiada? Funkcja globalna view()
wyrzuca wyjątek jeśli nie odnajdzie pliku widoku. W Laravelu wyjątki obsługiwane są przez klasę App\Exceptions\Handler
, która taki wyjątek z funkcji view()
zamienia na błąd 500. Na szczęście dla potrzeb testów możemy tą obsługę wyjątków tymczasowo wyłączyć. Wystarczy na początku testu wywołać następującą metodę:
public function the_posts_show_route_can_be_accessed()
{
$this->withoutExceptionHandling();
// reszta kodu testu
}
Gdy teraz uruchomimy testy, zobaczymy nowy błąd:
InvalidArgumentException: View [posts.show] not found.
Teraz już wiemy co musimy zrobić, dodajmy pusty plik resources/views/posts/show.blade.php
, usuńmy z testów linijkę z wywołaniem metody withoutExceptionHandling()
i uruchommy ponownie testy:
Failed asserting that '' contains "Wrabiał krowę w morderstwo cioci".
Wróciliśmy do poprzedniego błędu. A to dlatego, że zwracamy pusty widok. Poprawmy to:
// resources/views/posts/show.blade.php
<h1>{{ $post->title }}</h1>
Po uruchomieniu testów znowu dostajemy błąd 500:
Expected status code 200 but received 500.
Dodajmy ponownie na początku test wywołanie metody withoutExceptionHandling()
i uruchommy test ponownie. Powinniśmy zobaczyć następujący błąd:
Facade\Ignition\Exceptions\ViewException: Undefined variable: post
W widoku używamy zmiennej $post
, której nie przekazaliśmy w kontrolerze. Poprawmy to:
public function show(Post $post)
{
return view('posts.show')->with('post', $post);
}
Usuńmy z testów linijkę z wywołaniem metody withoutExceptionHandling()
i uruchommy ponownie test:
Sukces! Napisaliśmy pierwszy test dla naszej aplikacji i napisaliśmy kod który implementuje funkcjonalność opisaną w teście. Wiemy, że wchodząc pod adres /posts/1
zobaczymy na ekranie tytuł wpisu o id
równym 1. Żeby być tego pewnym, nie musieliśmy nawet włączać przeglądarki.
Kod napisany w tym wpisie znajdziecie na GitHubie.
W tej pierwszej części serii wpisów z tematu "Wprowadzenie do testowania w Laravelu" pominęliśmy dodanie do wpisu pola body
, w którym będziemy chcieli przechowywać zawartość wpisu. Pominęliśmy też temat relacji pomiędzy modelem Post
a modelem User
. Wreszcie, pominęliśmy sprawdzenie czy wpis został oznaczony jako opublikowany. Wszystko to po to, żeby wejście w temat testowania było jak najłatwiejsze. Tymi tematami zajmiemy się w części drugiej.