Kontynuujemy serię wpisów, w której budujemy wspólnie aplikację bloga pisząc do niej testy automatyczne przy użyciu techniki Test Driven Development. W czwartej części napisaliśmy testy sprawdzające walidację pól formularza dodawania wpisu oraz dowiedzieliśmy się jak automatycznie logować użytkownika, przed każdym testem za pomocą metody setUp
.
W tej części napiszemy testy dla funkcjonalności edytowania wpisu. Dane przesyłane przez formularz edycji wpisu będą walidowane, w związku z czym dodamy też testy sprawdzające walidację tego formularza. Jako wzór posłużą nam testy napisane w poprzedniej części. Na koniec dodamy funkcjonalność usuwania wpisu.
Edycja wpisu
W pierwszym wpisie tej serii ustaliliśmy, że edycji wpisu mogą dokonywać tylko zalogowani użytkownicy. Jak pamiętamy, w ostatnim wpisie wydzieliliśmy testy wymagające zalogowania użytkownika do osobnej klasy testowej PostsTest
znajdującej się w katalogu tests/Feature/Authenticated
. Klasa ta dziedziczy po klasie AuthenticatedTestCase
, w której w metodzie setUp()
logujemy użytkownika, przed każdym testem. Dzięki temu nie musimy tego robić bezpośrednio w teście sprawdzającym funkcjonalność edytowania wpisu. Napiszmy ten test:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function a_post_can_be_updated()
{
$post = factory(Post::class)->create();
$this->patch("/posts/{$post->id}" , [
'published_at' => '2019-11-19 12:00:00',
'title' => 'Odebrał żelazko zamiast telefonu',
'body' => 'Miał pomóc żonie, a skończyło się tragedią.',
]);
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'published_at' => '2019-11-19 12:00:00',
'title' => 'Odebrał żelazko zamiast telefonu',
'body' => 'Miał pomóc żonie, a skończyło się tragedią.',
]);
}
W pierwszej sekcji testu używamy fabryki modelu Post
i tworzymy wpis w bazie danych. Ponieważ chcemy sprawdzić w tym teście czy działa funkcjonalność edycji wpisu, oryginalny tytuł czy zawartość wpisu nie ma dla nas znaczenia. W drugiej sekcji wysyłamy zapytanie HTTP typu PATCH na adres /posts/{id}
. Jako drugi parametr metody patch()
przekazujemy tablicę z danymi formularza. W ostatniej sekcji dokonujemy asercji. Sprawdzamy czy w bazie danych jest wpis o podanym id
i danymi identycznymi z tymi przesłanymi w formularzu. Innymi słowy sprawdzamy czy pola naszego wpisu zostały zaktualizowane.
Gdy uruchomimy nasz test za pomocą komendy vendor/bin/phpunit --filter=a_post_can_be_updated
dostaniemy informację:
Failed asserting that a row in the table [posts] matches the attributes...
Dodajmy na początku testu $this->withoutExceptionHandling()
i uruchommy test ponownie:
Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException: The PATCH method is not supported for this route. Supported methods: GET, HEAD.
Mamy ścieżkę /posts/{id}
, która służy do wyświetlania wpisu, ale nie obsługuje ona metody PATCH. Prościej mówiąc - brakuje nam ścieżki. Dodajmy ją w pliku routes/web.php
pamiętając o tym, żeby umieścić ją w grupie ścieżek, dla których wymagane jest zalogowanie:
// routes/web.php
Route::middleware('auth')->group(function () {
Route::post('/posts', 'PostController@store');
Route::patch('/posts/{post}', 'PostController@update');
});
Gdy uruchomimy nasz test ponownie zobaczymy następujący błąd:
BadMethodCallException: Method App\Http\Controllers\PostController::update does not exist.
Dodajmy metodę update()
do naszego kontrolera:
// app/Http/Controllers/PostController.php
public function update(Request $request, Post $post)
{
// ...
}
Gdy uruchomimy test, wrócimy do początkowej informacji:
Failed asserting that a row in the table [posts] matches the attributes...
Na tym etapie możemy usunąć z testu wywołanie metody withoutExceptionHandling()
i zaimplementować kod w metodzie update()
:
// app/Http/Controllers/PostController.php
public function update(Request $request, Post $post)
{
$post->update($request->only([
'published_at',
'title',
'body',
]));
}
To powinno sprawić, że nasz test przejdzie.
Walidacja formularza edycji
W ostatnim wpisie napisaliśmy kilka testów sprawdzających walidację formularza dodawania wpisu. Test sprawdzające walidację formularza edycji wpisu będą do nich bardzo podobne. Zobaczmy przykładowy test z ostatniego wpisu:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function the_title_field_is_required()
{
$response = $this->post('/posts', [
'title' => null,
]);
$response->assertSessionHasErrors('title');
}
Sprawdzamy w nim, że po wysłaniu formularza z pustym tytułem, odpowiedź znajdująca się pod zmienną $response
zawiera w sesji błąd dla pola title
. Zobaczmy teraz jak będzie wyglądał test sprawdzający, że pole tytuł nie może być puste gdy przesyłamy formularz edycji:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function the_title_field_is_required_on_update()
{
$post = factory(Post::class)->create();
$response = $this->patch("/posts/{$post->id}", [
'title' => null,
]);
$response->assertSessionHasErrors('title');
}
Różnica polega tylko na tym, że zamiast zapytania POST pod adres tworzenia wpisu, musimy wykonać zapytanie PATCH pod adres już istniejącego wpisu.
Dodajmy też test sprawdzający, że tytuł wpisu jest unikatowy:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function the_title_field_must_be_unique_on_update()
{
$post = factory(Post::class)->create();
$otherPost = factory(Post::class)->create([
'title' => 'Wrabiał krowę w morderstwo cioci',
]);
$response = $this->patch("/posts/{$post->id}", [
'title' => 'Wrabiał krowę w morderstwo cioci',
]);
$response->assertSessionHasErrors('title');
}
Analogicznie możemy dodać testy sprawdzające, że przy edycji pole body
nie jest wymagane, ale jeśli jest wypełnione to musi mieć minimum trzy znaki:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function the_body_is_not_required_on_update()
{
$post = factory(Post::class)->create();
$response = $this->patch("/posts/{$post->id}", [
'body' => null,
]);
$response->assertSessionDoesntHaveErrors('body');
}
/** @test */
public function the_body_must_be_at_least_3_characters_on_update()
{
$post = factory(Post::class)->create();
$response = $this->patch("/posts/{$post->id}", [
'body' => 'aa',
]);
$response->assertSessionHasErrors('body');
}
Oraz podobne testy dla pola published_at
, które nie jest wymagane, ale jeśli jest wypełnione to musi być datą:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function the_published_at_is_not_required_on_update()
{
$post = factory(Post::class)->create();
$response = $this->patch("/posts/{$post->id}", [
'published_at' => null,
]);
$response->assertSessionDoesntHaveErrors('published_at');
}
/** @test */
public function the_published_at_must_be_a_valid_date_on_update()
{
$post = factory(Post::class)->create();
$response = $this->patch("/posts/{$post->id}", [
'published_at' => 'NOT-A-DATE-STRING',
]);
$response->assertSessionHasErrors('published_at');
}
By sprawić, że te testy przejdą, możemy w kontrolerze PostController
skopiować do metody update()
linijki definiujące reguły walidacji z metody store()
:
// app/Http/Controllers/PostController.php
public function update(Request $request, Post $post)
{
$request->validate([
'title' => [
'required',
Rule::unique('posts'),
],
'body' => [
'nullable',
'min:3',
],
'published_at' => [
'nullable',
'date',
],
]);
$post->update($request->only([
'published_at',
'title',
'body',
]));
}
Wszystkie testy powinny przejść. Nasz formularz edycji będzie miał jeden spory problem, ale o tym za chwilę.
Przeniesienie walidacji do klasy PostRequest
Zwróćmy uwagę na to, że w obu metodach store()
i update()
zapisujących formularze w naszym kontrolerze używamy tych samych reguł walidacji. Dodatkowo, te metody zrobiły się dosyć długie. To odpowiedni moment, żeby przenieść walidację do dedykowanej klasy typu FormRequest
. Stwórzmy klasę PostRequest
za pomocą komendy php artisan make:request PostRequest
i przenieśmy tam reguły walidacji z kontrolera:
// app/Http/Requests/PostRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PostRequest extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'title' => [
'required',
Rule::unique('posts'),
],
'body' => [
'nullable',
'min:3',
],
'published_at' => [
'nullable',
'date',
],
];
}
}
Na chwilę obecną nie mamy dodatkowych zasad dotyczących autoryzacji tego żądania, w związku z czym z metody authorize()
powinniśmy zwrócić wartość true
. Przeróbmy jeszcze kod kontrolera. W metodach store()
oraz update()
usuwamy wywołanie $request->validate()
i zmieniamy typehint zmiennej $request
z klasy Illuminate\Http\Request
na nasze PostRequest
:
// app/Http/Controllers/PostController.php
use App\Http\Requests\PostRequest;
public function store(PostRequest $request)
{
$request->user()->posts()->create($request->only([
'published_at',
'title',
'body',
]));
}
public function update(PostRequest $request, Post $post)
{
$post->update($request->only([
'published_at',
'title',
'body',
]));
}
Na szczęście, dzięki testom możemy szybko sprawdzić, że po wprowadzonych zmianach wszystko nadal działa jak należy:
Ignorowanie obecnego tytułu przy sprawdzeniu unikatowości
Jak wspomniałem wcześniej, mamy problem w formularzu edycji. Dodaliśmy dla pola title
regułę unique
, która sprawdza czy podany tytuł jest unikatowy. W obecnej formie, jeśli będziemy chcieli edytować wpis nie zmieniając jego tytułu, walidacja nie przejdzie. Mechanizm walidacji przeszuka wszystkie wpisy i jeśli znajdzie chociaż jeden z podanym tytułem to zwróci błąd. Musimy poinstruować mechanizm walidacji, żeby przy szukał tytułu we wszystkich wpisach oprócz tego, który obecnie edytujemy. Napiszmy test opisujący taki scenariusz:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function the_current_title_is_ignored_for_the_unique_check_on_update()
{
$post = factory(Post::class)->create([
'title' => 'Wrabiał krowę w morderstwo cioci',
]);
$response = $this->patch("/posts/{$post->id}", [
'title' => 'Wrabiał krowę w morderstwo cioci',
]);
$response->assertSessionHasNoErrors('title');
}
W pierwszej sekcji tworzymy test o konkretnym tytule. Następnie wysyłamy formularz edycji przesyłając ten sam tytuł. W ostatniej sekcji sprawdzamy, że dla pola title
nie ma błędów walidacji. Innymi słowy, że walidacja tego pola przeszła. Pozostaje nam zmodyfikować kod klasy PostRequest
:
// app/Http/Requests/PostRequest.php
public function rules()
{
return [
'title' => [
'required',
Rule::unique('posts')->ignoreModel($this->route('post')),
],
// ...
];
}
Nasz test przejdzie, ale gdy uruchomimy wszystkie testy, zobaczymy że testy dotyczące dodawania wpisu oraz te dotyczące walidacji formularza dodawania wpisu nie przechodzą. Definiując regułę używamy zapisu ignoreModel($this->route('post'))
. Nasza klasa PostRequest
dziedziczy po klasie Illuminate\Http\Request
. Jej metoda route()
zwraca to co znajduje się w ścieżce pod podanym parametrem. Definiując ścieżkę i kontroler używamy Route Model Binding, w związku z czym metoda route('post')
zwróci konkretny model Post
. Gdy przesyłamy formularz dodawania wpisu, nie mamy id wpisu w ścieżce i metoda route('post')
zwróci null
. Jednocześnie metoda ignoreModel()
oczekuje modelu i dlatego pojawia się błąd.
Zazwyczaj reguły walidacji formularzy dodawania i edytowania danego zasobu różnią się znacznie. Warto wtedy użyć dedykowanych klas FormRequest
dla obu przypadków. Ja pozostanę jednak przy jednej klasie i zmodyfikuję lekko kod, żeby walidacja działała prawidłowo:
// app/Http/Requests/PostRequest.php
public function rules()
{
return [
'title' => [
'required',
$this->route('post') ? Rule::unique('posts')->ignoreModel($this->route('post')) : Rule::unique('posts'),
],
// ...
];
}
Jeśli w ścieżce dostępny jest parametr post
to walidator powinien go zignorować przy sprawdzeniu unikatowości. W przeciwnym wypadku reguła pozostaje bez zmian. Teraz wszystkie testy powinny przechodzić.
Usuwanie wpisu
Na koniec dodajmy test sprawdzający, że wpis można usuwać:
/** @test */
public function a_post_can_be_deleted()
{
$post = factory(Post::class)->create();
$this->delete("/posts/{$post->id}");
$this->assertDatabaseMissing('posts', [
'id' => $post->id,
]);
}
W pierwszej kolejności tworzymy wpis w bazie danych. Następnie wysyłamy zapytanie typu DELETE na adres usuwania wpisu. Na koniec używamy asercji assertDatabaseMissing()
, żeby sprawdzić że wpisu o podanym id
nie ma już w bazie danych.
By ten test przeszedł będziemy musieli dodać ścieżkę:
// routes/web.php
Route::middleware('auth')->group(function () {
Route::post('/posts', 'PostController@store');
Route::patch('/posts/{post}', 'PostController@update');
Route::delete('/posts/{post}', 'PostController@destroy');
});
Oraz metodę destroy()
w kontrolerze:
// app/Http/Controllers/PostController.php
public function destroy(Post $post)
{
$post->delete();
}
W tej piątej części serii wpisów z tematu "Wprowadzenie do testowania w Laravelu" napisaliśmy testy dla funkcjonalności edytowania oraz usuwania wpisu. Napisaliśmy też testy sprawdzające walidację formularza edycji i przy okazji uporządkowaliśmy kod kontrolera przenosząc reguły walidacji do dedykowanej klasy PostRequest
.
Kod napisany w tym wpisie znajdziecie na GitHubie.
W następnym wpisie serii przerobimy kod napisany w tej części w taki sposób, żeby użytkownicy mogli edytować tylko swoje wpisy, natomiast usuwać wpisy będzie mógł tylko administrator.
Jeśli macie jakiekolwiek pytania dotyczące wpisu, zapraszamy do komentarzy poniżej lub na naszego Slacka!