Wprowadzenie do testowania w Laravelu - część 5

Paweł Mysior
10 grudnia 2019

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()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:

terminal

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 FormRequestdla 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!

Wygodny hosting zapewnia duet DigitalOceanLaravel Forge.
Copyright © laravelpolska.com

Drogi Użytkowniku!

Dalsze korzystanie z serwisu bez zmiany ustawień dotyczących cookies w przeglądarce oznacza akceptację plików cookies, co będzie skutkowało zapisywaniem ich na Twoich urządzeniach przez serwis internetowy laravelpolska.com. Jeśli nie wyrażasz zgody na przyjmowanie cookies, prosimy o zmianę ustawień w przeglądarce lub o opuszczenie serwisu. więcej

Stosujemy pliki cookies (tzw. ciasteczka) i inne pokrewne technologie, które mają na celu:

  • dostosowanie zawartości stron internetowych Serwisu do Twoich preferencji oraz optymalizacji korzystania ze stron internetowych; w szczególności pliki te pozwalają rozpoznać Twoje urządzenie i odpowiednio wyświetlić stronę internetową, dostosowaną do Twoich indywidualnych potrzeb;
  • utrzymanie Twojej sesji w Serwisie (po zalogowaniu), dzięki czemu nie musisz na każdej podstronie Serwisu ponownie wpisywać loginu i hasła,
  • zapewnienie bezpieczeństwa podczas korzystania z Serwisu,
  • ulepszenie świadczonych przez nas usług poprzez wykorzystanie danych w celach analitycznych i statystycznych,
  • poznanie Twoich preferencji na podstawie sposobu korzystania z naszych serwisów.

Wykorzystanie cookies pozwala nam zapewnić maksymalną wygodę przy korzystaniu z naszego Serwisu poprzez zapamiętanie Waszych preferencji i ustawień na naszych stronach. Więcej informacji o zamieszczanych plikach cookie oraz o możliwości zmiany ustawień przeglądarki oraz polityce przetwarzania danych znajdziesz w polityce prywatności.

Masz możliwość samodzielnej zmiany ustawień dotyczących cookies w swojej przeglądarce internetowej. Z poziomu przeglądarki internetowej, z której korzystasz, możliwe jest zarządzanie plikami cookies. W najpopularniejszych przeglądarkach istnieje m.in. możliwość:

  • zaakceptowania obsługi cookies, co pozwala na pełne korzystanie z opcji oferowanych przez witryny internetowe;
  • zarządzania plikami cookies na poziomie pojedynczych, wybranych przez użytkownika witryn;
  • określania ustawień dla różnych typów plików cookies, na przykład akceptowania plików stałych, jako sesyjnych itp.;
  • blokowania lub usuwania cookies.

Akceptuję pliki cookies