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 pierwszej części dowiedzieliśmy się jak używać narzędzia PHPUnit i napisaliśmy pierwszy test naszej aplikacji. Sprawdzamy w nim, że po wejściu na adres /posts/1
zobaczymy w przeglądarce tytuł wpisu o id
równym 1. Następnie po kolei pisaliśmy kod, który implementuje funkcjonalność opisaną w tym teście, uruchamiając nasz test po każdej zmianie, aż do momentu, w którym test przeszedł.
Na wstępie pierwszego wpisu ustaliliśmy, że oprócz tytułu, wpis będzie też miał pole body
na przechowywanie swojej treści. Mieliśmy też wyświetlać tylko wpisy oznaczone jako opublikowane. Tymi tematami zajmiemy się w tej części serii.
body
i automatyczne migrowanie bazy danych
Dodanie pola Chcemy dodać pole body
do naszego modelu Post
. Pamiętamy, że tworzymy aplikację za pomocą techniki TDD. Czyli zaczynamy od napisania testu. Sprawdzimy w nim czy po wejściu na stronę posts/{id}
zobaczymy w przeglądarce wartość pola body
danego wpisu. Test nie będzie skomplikowany, będzie w zasadzie analogiczny do tego napisanego w pierwszej części, ale pozwoli nam poznać nowe zagadnienie związane z testowaniem, czyli automatyczne migrowanie bazy danych przed testami. Zacznijmy od dodania w naszej klasie PostsTest
nowego testu:
// tests/Feature/PostsTest.php
/** @test */
public function the_body_attribute_is_shown_on_the_posts_show_view()
{
$post = Post::create([
'body' => 'Mroczna tajemnica mordu w oborze długo spędzała sen z oczu policjantom z Lublina. Kto zabił 88-letnią kobietę i jej krowę?',
]);
$response = $this->get('/posts/' . $post->id);
$response->assertSeeText('Mroczna tajemnica mordu w oborze długo spędzała sen z oczu policjantom z Lublina. Kto zabił 88-letnią kobietę i jej krowę?');
}
Tworzymy wpis o konkretnej treści, wchodzimy na stronę ze wpisem i sprawdzamy czy w odpowiedzi znajduje się ta treść. Możemy teraz uruchomić naszą klasę testową:
vendor/bin/phpunit tests/Feature/PostsTest.php
Powinniśmy w terminalu zobaczyć następujący wynik:
ERRORS!
Tests: 2, Assertions: 2, Errors: 1.
Wykonane zostały dwa testy. Test z pierwszej części przeszedł, ten dodany przed chwilą nie. Ma to sens, nie napisaliśmy jeszcze jego implementacji. W trakcie pisania tej implementacji będziemy uruchamiać ten test kilkukrotnie. Za każdym razem będzie się też uruchamiał pierwszy test. Byłoby dobrze gdybyśmy mogli uruchamiać tylko jeden test (czyli jedną metodę z klasy PostsTest
). Możemy użyć do tego opcji --filter
programu PHPUnit
. Spróbujmy wpisać w terminalu następującą komendę:
vendor/bin/phpunit --filter=the_body_attribute_is_shown_on_the_posts_show_view
Program przeszuka wszystkie klasy w katalogu tests
w poszukiwaniu testów o podanej przez nas nazwie, znajdzie nasz test i wykona tylko ten jeden:
ERRORS!
Tests: 1, Assertions: 0, Errors: 1.
Ok, zobaczmy jaki błąd nam się pojawia:
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1364 Field 'title' doesn't have a default value
Baza danych zwraca błąd, że kolumna title
nie ma domyślnej wartości. Rzeczywiście, w tym teście dodajemy wpis nie podając wartości pola title
. Jednocześnie w definicji tabeli posts
nie podaliśmy domyślnej wartości. SQL nie wie jak dodać rekord. Ustawmy wartość pola title
przy dodawaniu wpisu:
// tests/Feature/PostsTest.php
/** @test */
public function the_body_attribute_is_shown_on_the_posts_show_view()
{
$post = Post::create([
'title' => 'Wrabiał krowę w morderstwo cioci',
'body' => 'Mroczna tajemnica mordu w oborze długo spędzała sen z oczu policjantom z Lublina. Kto zabił 88-letnią kobietę i jej krowę?',
]);
$response = $this->get('/posts/' . $post->id);
$response->assertSeeText('Mroczna tajemnica mordu w oborze długo spędzała sen z oczu policjantom z Lublina. Kto zabił 88-letnią kobietę i jej krowę?');
}
Uruchommy test ponownie. Powinniśmy zobaczyć następujący błąd.
Failed asserting that 'Wrabiał krowę w morderstwo cioci\n
' contains "Mroczna tajemnica mordu w oborze długo spędzała sen z oczu policjantom z Lublina. Kto zabił 88-letnią kobietę i jej krowę?".
W widoku posts/show.blade
nie wyświetlamy treści wpisu. Poprawmy to:
// resources/views/posts/show.blade.php
<h1>{{ $post->title }}</h1>
<p>{{ $post->body }}</p>
Gdy uruchomimy test ponownie, błąd się nie zmieni. Wartość {{ $post->body }}
jest pusta, pomimo tego, że zapisujemy ją w metodzie Post::create()
. Aha, pewnie zapomnieliśmy ją dodać do pola fillable
w modelu Post
. Poprawmy to:
app/Post.php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['title', 'body'];
}
Uruchamiamy test i dostajemy błąd. Ale przynajmniej jesteśmy coraz bliżej bo jest to nowy błąd:
Illuminate\Database\QueryException: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'body' in 'field list'
Nie mamy kolumny body
w tabeli posts
w bazie danych. Poprawmy migrację:
// database/migrations/2019_11_05_074454_create_posts_table.php
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('title');
$table->text('body');
$table->timestamps();
});
}
Uruchamiamy test i niestety wciąż nie przechodzi. Zapomnieliśmy zmigrować od nowa bazę danych. Możemy puścić w terminalu komendę php artisan migrate:fresh
, która usunie wszystkie tabele i uruchomi migracje na nowo. Ale możemy też zautomatyzować ten proces. Wystarczy że użyjemy w naszej klasie PostsTest
traita RefreshDatabase
:
// test/Feature/PostsTest.php
namespace Tests\Feature;
use App\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostsTest extends TestCase
{
use RefreshDatabase;
// ...
}
PHPUnit pozwala dodać kod, który ma się wykonać na początku i na końcu działania programu. Czyli przed wykonaniem pierwszego testu i po zakończeniu ostatniego. Pozwala też dodać kod, który wykona się przed każdym i po każdym pojedynczym teście. Trait RefreshDatabase
wykorzystuje tę możliwość i przed wykonaniem pierwszego testu puszcza komendę php artisan migrate:fresh
za nas. Oprócz tego, przed każdym pojedynczym testem rozpoczyna na bazie danych transakcję, która po zakończeniu testu jest cofana. Innymi słowy, wszystkie operacje na bazie danych, które wykonamy w trakcie testu zostaną po jego zakończeniu cofnięte. Dzięki czemu w następnym teście mamy znowu do czynienia z pustą bazą danych.
Gdy teraz uruchomimy nasz test, powinniśmy zobaczyć na ekranie wynik:
OK (1 test, 1 assertion)
Świetnie, nasz test przeszedł. Uruchommy teraz oba testy z naszej klasy za pomocą komendy vendor/bin/phpunit tests/Feature/PostsTest.php
, żeby zobaczyć czy czegoś nie zepsuliśmy:
1) Tests\Feature\PostsTest::the_posts_show_route_can_be_accessed
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1364 Field 'body' doesn't have a default value...
...
ERRORS!
Tests: 2, Assertions: 1, Errors: 1.
Nasz drugi test przeszedł, ale pierwszy test zwraca błąd. Tworząc w nim w bazie danych wpis nie podajemy wartości pola body
i baza danych nie wie jak stworzyć rekord. Moglibyśmy zmodyfikować kod testu i analogicznie jak w drugim teście, dodać pole body
przy tworzeniu wpisu. Ale możemy to zrobić lepiej używając fabryki model Post
.
Użycie fabryki modelu w testach
Zazwyczaj w pierwszej sekcji testu (arrange) będziemy musieli dodać jakieś modele do bazy danych. Zamiast tak jak do tej pory, podawać wartość każdej kolumny, możemy użyć fabryki modelu, w której będziemy mieli zdefiniowane domyślne wartości kolumn. Zobaczmy przykładową fabrykę UserFactory
:
// database/factories/UserFactory.php
use App\User;
use Faker\Generator as Faker;
use Illuminate\Support\Str;
$factory->define(User::class, function (Faker $faker) {
return [
'name' => $faker->name,
'email' => $faker->unique()->safeEmail,
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
];
});
Używamy metody define()
, do której jako pierwszy parametr podajemy wskazanie na klasę modelu, a jako drugi podajemy funkcję anonimową. W tej funkcji zwracamy tablicę kolumn z wartościami które zostaną użyte do stworzenia modelu w bazie danych. Dostajemy tu możliwość użycia biblioteki Faker, która pozwala łatwo generować losowe dane.
Gdybyśmy chcieli teraz stworzyć model klasy User
w bazie danych używając zdefiniowanej fabryki, wystarczy że użyjemy funkcji globalnej factory()
i dołączymy do niej metodę create()
:
use App\User;
$user = factory(User::class)->create();
Możemy wygenerować fabrykę dla naszego modelu Post
za pomocą artisana:
php artisan make:factory --model=Post PostFactory
Zmodyfikujmy treść wygenerowanej fabryki:
// database/factories/PostFactory.php
use App\Post;
use Faker\Generator as Faker;
$factory->define(Post::class, function (Faker $faker) {
return [
'title' => $faker->sentence,
'body' => $faker->paragraph,
];
});
Do pola title
przypiszemy wartość, którą zwróci klasa Faker
z pola sentence
. Będzie to kilka losowych słów z łaciny przypominających tekst "lorem ipsum". Do pola body
z kolei przypiszemy wartość z pola paragraph
klasy Faker
. Ponownie będzie to jakieś "lorem ipsum", tylko trochę dłuższe.
Teraz użyjmy fabryki modelu Post
w naszych testach:
// tests/Feature/PostsTest.php
namespace Tests\Feature;
use App\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostsTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function the_posts_show_route_can_be_accessed()
{
// Arrange
// Dodajemy do bazy danych wpis
$post = factory(Post::class)->create([
'title' => 'Wrabiał krowę w morderstwo cioci',
]);
// Act
// Wykonujemy zapytanie pod adres wpisu
$response = $this->get('/posts/' . $post->id);
// Assert
// Sprawdzamy że w odpowiedzi znajduje się tytuł wpisu
$response->assertStatus(200)
->assertSeeText('Wrabiał krowę w morderstwo cioci');
}
/** @test */
public function the_body_attribute_is_shown_on_the_posts_show_view()
{
$post = factory(Post::class)->create([
'body' => 'Mroczna tajemnica mordu w oborze długo spędzała sen z oczu policjantom z Lublina. Kto zabił 88-letnią kobietę i jej krowę?',
]);
$response = $this->get('/posts/' . $post->id);
$response->assertSeeText('Mroczna tajemnica mordu w oborze długo spędzała sen z oczu policjantom z Lublina. Kto zabił 88-letnią kobietę i jej krowę?');
}
}
Gdy używamy fabryki modelu możemy do metody create()
przekazać tablicę parametrów, których chcemy użyć zamiast tych domyślnie zdefiniowanych w fabryce. W pierwszym teście istotna jest dla nas wartość kolumny title
, dlatego ją przekazujemy. Wartość kolumny body
pomijamy, zostanie uzupełniona domyślną wartością zdefiniowaną w fabryce. W drugim teście sytuacja jest odwrotna. Podajemy wartość kolumny body
, bo w tym teście to ona jest istotna. Zawartość pola title
nie ma znaczenia.
Gdy teraz uruchomimy testy z naszej klasy PostsTest
powinniśmy zobaczyć:
OK (2 tests, 3 assertions)
Świetnie, mamy już dwa testy. Sprawdzamy w nich czy w widoku wpisu widać jego tytuł i treść. Zanim przejdziemy do następnego kroku, w którym przetestujemy bardziej skomplikowaną funkcjonalność, pozwolę sobie na małą dygresję.
Do tej pory, za każdym razem gdy dodawaliśmy do tabeli posts
nową kolumnę musieliśmy też dodać ją do pola fillable
w modelu Post
. Laravel wymusza to na nas, chcąc ochronić nas przed sytuacją, w której złośliwy użytkownik doda nowe pole do formularza, którego wartość my następnie nieświadomie dodamy do bazy danych używając następującego kodu: Post::create($request->all())
. Więcej na ten temat możecie przeczytać w tym wpisie. Osobiście, w aplikacjach które tworzę, nigdy nie używam metody $request->all()
. Zamiast tego używam metody $request->only()
, na przykład $request->only(['title', 'body'])
, czyli wyraźnie wskazuję w kodzie nazwy pól, które mają być zapisane z danego formularza. Jest to dla mnie rozwiązanie czytelniejsze niż użycie $request->all()
. Dzięki temu, nie potrzebuję ochrony przed wcześniej opisaną sytuacją. Dlatego zawsze wyłączam tę ochronę. Można to zrobić ustawiając na przykład w modelu pole guarded
na pustą tablicę.
// app/Post.php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $guarded = [];
}
Pole guarded
to odwrotność pola fillable
. Jeśli jest pustą tablicą, to znaczy, że nie chcemy chronić żadnego pola. Możemy też w AppServiceProvider
wywołać statyczną metodę unguard()
na klasie Model
:
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Model::unguard();
}
}
Więcej wskazówek tego typu znajdziecie na naszej stronie na facebooku.
Oznaczanie wpisów jako opublikowane
Na wstępie ustaliliśmy, że będziemy chcieli na liście wpisów wyświetlać tylko opublikowane wpisy. Na tym etapie musimy podjąć decyzję jak będziemy oznaczać wpisy jako opublikowane. Najprościej byłoby dodać w tabeli posts
pole typu boolean
o nazwie is_published
. Gdy w kolumnie jest wartość true
- wpis jest opublikowany. W takiej sytuacji nie mamy jednak możliwości ustawiać publikacji wpisu na konkretną datę w przyszłości. Zamiast używać kolumny typu boolean
proponuję dodać w tabeli posts
kolumnę typu datetime
o nazwie published_at
. Wpis będzie opublikowany gdy wartość kolumny published_at
będzie datą w przeszłości, a precyzyjniej mówiąc - gdy będzie datą nie w przyszłości. Wpis będzie natomiast oznaczony jako nieopublikowany gdy kolumna published_at
będzie miała wartość null
lub będzie datą w przyszłości.
Zacznijmy od napisania testu.
// tests/Feature/PostsTest.php
use Carbon\Carbon;
/** @test */
public function only_published_posts_are_shown_on_the_posts_index_view()
{
$publishedPost = factory(Post::class)->create([
'published_at' => Carbon::yesterday(),
]);
$unpublishedPost = factory(Post::class)->create([
'published_at' => Carbon::tomorrow(),
]);
$response = $this->get('/posts');
$response->assertStatus(200)
->assertSeeText($publishedPost->title)
->assertDontSeeText($unpublishedPost->title);
}
W pierwszej sekcji testu (arrange) dodajemy do bazy danych dwa wpisy. W obu ustawiamy datę w kolumnie published_at
za pomocą biblioteki do manipulacji czasu Carbon. W pierwszym ustawiamy datę publikacji na wczoraj. W drugim ustawiamy datę publikacji na jutro. Warto zwrócić tu uwagę, na to że te daty będą zawsze względne do daty uruchomienia testu. W drugiej sekcji testu (act) wykonujemy zapytanie GET pod ścieżkę /posts
. W ostatniej sekcji (assert) używamy trzech asercji. Najpierw sprawdzamy czy ścieżka /posts
zwróciła jakąkolwiek poprawną odpowiedź. Następnie sprawdzamy, że w treści tej odpowiedzi widać tytuł wpisu oznaczonego jako opublikowany oraz, że nie widać tytułu wpisu oznaczonego jako nieopublikowany.
Uruchommy nasz test i zobaczmy błąd:
Illuminate\Database\QueryException: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'published_at' in 'field list'
Dodajmy pole published_at
do migracji tabeli posts
:
// database/migrations/2019_11_05_074454_create_posts_table.php
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->datetime('published_at')->nullable();
$table->string('title');
$table->text('body');
$table->timestamps();
});
}
I uruchommy test ponownie:
Expected status code 200 but received 404.
Brakuje nam ścieżki, dodajmy ją:
// routes/web.php
Route::get('/posts', 'PostController@index');
Uruchommy test ponownie:
Expected status code 200 but received 500.
Pojawił się błąd 500. Gdy na początku testu wywołamy metodę withoutExceptionHandling()
i uruchomimy test ponownie zobaczymy, że problemem jest brak metody index()
w naszym kontrolerze.
Zgodnie z techniką TDD powinniśmy, za każdym razem dodawać jak najmniejszą ilość kodu, która pozwoli po ponownym uruchomieniu testu zobaczyć nowy błąd. Pozwolę sobie, w celu zachowania zwięzłości tego wpisu, przeskoczyć kilka takich kroków. Oto kod kontrolera:
// app/Http/Controllers/PostController.php
public function index()
{
$posts = Post::get();
return view('posts.index')->with('posts', $posts);
}
oraz widok:
// resources/views/posts/index.blade.php
<ul>
@foreach ($posts as $post)
<li>
<a href="/posts/{{ $post->id }}">
{{ $post->title }}
</a>
</li>
@endforeach
</ul>
Gdy teraz uruchomimy test zobaczymy błąd wyglądający mniej więcej tak:
Failed asserting that '\n
\n
\n
Omnis ut aut voluptas sapiente impedit et molestias voluptatum.\n
\n
\n
\n
\n
Mollitia laboriosam eum et iure.\n
\n
\n
\n
' does not contain "Mollitia laboriosam eum et iure.".
Doszliśmy do ostatniej asercji assertDontSeeText()
. Tytuł nieopublikowanego wpisu, chociaż nie powinien, znalazł się w odpowiedzi. Pobierzmy w kontrolerze tylko wpisy, dla których data w kolumnie published_at
jest mniejsza niż obecna:
// app/Http/Controllers/PostController.php
use Carbon\Carbon;
$posts = Post::where('published_at', '<', Carbon::now())->get();
Gdy uruchomimy test, powinien przejść. Przypomnijmy jednak, że wpis miał być traktowany jako opublikowany gdy kolumna published_at
nie ma wartości null
oraz gdy jest datą nie w przyszłości. W teście sprawdzamy tylko wpisy, które mają datę publikacji ustawioną na wczoraj i na jutro. Nie sprawdzamy sytuacji, w której data publikacji ustawiona jest na wartość null
. Nie sprawdzamy też sytuacji, w której data publikacji jest ustawiona na obecną, czyli Carbon::now()
. Nie wiemy czy w tych sytuacjach instrukcja where
, której użyliśmy w kontrolerze się sprawdzi. Powinniśmy dodać w teście dwa kolejne wpisy i dwie kolejne asercje. To sprawiłoby, że nasz test przestałby przechodzić. Powinniśmy następnie zmodyfikować instrukcję where
w kontrolerze, żeby test przeszedł. Ostatecznie kod do pobrania opublikowanych wpisów wyglądałby mniej więcej tak:
$posts = Post::whereNotNull('published_at')->where('published_at', '<=', Carbon::now())->get();
Mielibyśmy jednak w kontrolerze kod, który jest mało zrozumiały. Gdy wrócimy do tej linijki za pół roku, nie będziemy pamiętać dlaczego te warunki są potrzebne. Albo będziemy musieli poświęcić co najmniej kilka sekund, na to żeby sobie to przypomnieć. Dobrze byłoby schować te warunki w dobrze nazwanej funkcji. W Eloquencie świetnie nadają się do tego tak zwane query scopes, które pozwalają zdefiniować nam taki zestaw instrukcji where
w funkcji na modelu. Możemy na przykład zdefiniować scope published()
, żeby łatwo pobierać z bazy danych tylko opublikowane wpisy. Żeby zdefiniować taki scope, wystarczy poprzedzić nazwę funkcji z modelu prefiksem scope
. W tym przypadku funkcja powinna nazywać się scopePublished()
. Możemy użyć jej w kontrolerze taki sposób:
$posts = Post::published()->get();
Żeby powyższy kod zadziałał, musimy zdefiniować funkcję scopePublished()
na naszym modelu Post
. Proponuję w tym celu napisać nowy test, tym razem jednostkowy.
Testy funkcjonalne a testy jednostkowe
Testy, które napisaliśmy do tej pory były testami funkcjonalnymi (ang. feature test). Jak sama nazwa wskazuje, sprawdzmy w nich konkretną funkcjonalność całej aplikacji. Na przykład sprawdzamy, że po wejściu na adres /posts
w odpowiedzi znajdą się tylko opublikowane wpisy. Innym rodzajem testów są testy jednostkowe (ang. unit test), w których sprawdzamy jedną konkretną funkcję kodu, zazwyczaj w ramach jednej klasy. Na przykład sprawdzamy, że query scope o nazwie published()
zdefiniowany na modelu Post
zawiera tylko opublikowane wpisy.
Różnicę między tymi typami testów można wytłumaczyć w taki sposób: testy jednostkowe pisane są z perspektywy programisty. Pisane są w celu zapewnienia, że określony kawałek kodu wykonuje zestaw określonych zadań. Testy funkcjonalne pisane są z perspektywy użytkownika. Zapewniają, że system działa tak jak oczekują tego użytkownicy.
Najłatwiej będzie zobaczyć różnicę na przykładzie. Użyjmy artisana w celu wygenerowania sobie nowego testu jednostkowego:
php artisan make:test --unit PostTest
Napiszmy w wygenerowanej klasie PostTest
pierwszy test, sprawdzający że wpisy mające w kolumnie published_at
wartość null
nie znajdą się wśród wpisów, które pobierzemy z bazy danych za pomocą query scope published()
:
// tests/Unit/PostTest.php
namespace Tests\Unit;
use App\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function if_the_published_at_is_null_the_post_is_not_published()
{
$post = factory(Post::class)->create([
'published_at' => null,
]);
$posts = Post::published()->get();
$this->assertFalse($posts->contains($post));
}
}
Drobna uwaga: powyższy test korzysta z bazy danych, w związku z czym nie jest prawdziwym testem jednostkowym. Zgodnie z przyjętą definicją, test jednostkowy powinien testować klasę Post
w odosobnieniu, niezależnie od innych klas i zewnętrznych systemów. Połączenie z bazą danych powinno być w takim teście zastąpione mockiem. Użycie w teście bazy danych wydłuża czas trwania testu. Jednocześnie, z mojego doświadczenia wynika, że testowanie modeli bez faktycznego użycia bazy danych jest rozwiązaniem dość kruchym. Przy użyciu Eloquenta jest to też mocno toporne. Osobiście wolę po prostu używać w testach bazy danych, daje mi to większą pewność, że mój kod rzeczywiście robi to co powinien. Dla zainteresowanych, polecam na ten temat prelekcję Adama Wathana z Laracon Online 2017.
Uruchommy nasz test i zobaczmy błąd:
BadMethodCallException: Call to undefined method App\Post::published()
Dodajmy zatem w naszym modelu Post
query scope published()
:
// app/Post.php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
public function scopePublished($query)
{
return $query;
}
}
Funkcja typu scope dostaje jako parametr instancję klasy \Illuminate\Database\Eloquent\Builder
pod zmienną $query
, na której możemy wykonać dowolne warunki jak na zwykłym query builderze Eloquenta. Powinniśmy z tej funkcji zwrócić zmienną query
. Uruchommy test ponownie:
Failed asserting that true is false.
W porządku, nie ma już błędu, ale test nie przechodzi. Dodaliśmy query scope, ale nie dodaliśmy żadnych warunków. Dodajmy do zapytania w funkcji scopePublished()
warunek:
public function scopePublished($query)
{
return $query->whereNotNull('published_at');
}
Teraz test powinien przejść. Napiszmy zatem następny:
use Carbon\Carbon;
/** @test */
public function if_the_published_at_is_in_the_future_the_post_is_not_published()
{
$post = factory(Post::class)->create([
'published_at' => Carbon::tomorrow(),
]);
$posts = Post::published()->get();
$this->assertFalse($posts->contains($post));
}
Sprawdzamy, że wpisy mające ustawioną wartość kolumny published_at
na datę z przyszłości nie znajdą się wśród wpisów zwróconych przez query scope published()
. Po uruchomieniu testu zobaczymy:
Failed asserting that true is false.
Dodajmy drugą instrukcję where w funkcji scopePublished()
:
use Carbon\Carbon;
public function scopePublished($query)
{
return $query->whereNotNull('published_at')->where('published_at', '<', Carbon::now());
}
Teraz test powinien przejść. Napiszmy następny. Sprawdźmy, że wpisy mające ustawione wartość kolumny published_at
na datę z przeszłości znajdą się wśród wpisów zwróconych przez query scope published()
:
/** @test */
public function if_the_published_at_is_in_the_past_the_post_is_published()
{
$post = factory(Post::class)->create([
'published_at' => Carbon::yesterday(),
]);
$posts = Post::published()->get();
$this->assertTrue($posts->contains($post));
}
Ten test powinien przejść bez żadnych modyfikacji w kodzie. Pozostał nam scenariusz gdy wpis ma datę publikacji ustawioną na Carbon::now()
. Napiszmy test:
/** @test */
public function if_the_published_at_is_now_the_post_is_published()
{
$post = factory(Post::class)->create([
'published_at' => Carbon::now(),
]);
$posts = Post::published()->get();
$this->assertTrue($posts->contains($post));
}
Gdy uruchomimy test zobaczymy:
Failed asserting that false is true.
Poprawmy warunek w funkcji scopePublished()
:
public function scopePublished($query)
{
return $query->whereNotNull('published_at')->where('published_at', '<=', Carbon::now());
}
Teraz wszystkie cztery testy z pliku tests/Unit/PostTest.php
powinny przechodzić. Mamy na tym etapie pewność, że query scope published()
działa poprawnie. Możemy zatem użyć go w kontrolerze PostController
:
// app/Http/Controllers/PostController.php
public function index()
{
$posts = Post::published()->get();
return view('posts.index')->with('posts', $posts);
}
Przypomnijmy jak wygląda test z pliku tests/Feature/PostsTest.php
o nazwie only_published_posts_are_shown_on_the_posts_index_view
:
/** @test */
public function only_published_posts_are_shown_on_the_posts_index_view()
{
$publishedPost = factory(Post::class)->create([
'published_at' => Carbon::yesterday(),
]);
$unpublishedPost = factory(Post::class)->create([
'published_at' => Carbon::tomorrow(),
]);
$response = $this->get('/posts');
$response->assertStatus(200)
->assertSeeText($publishedPost->title)
->assertDontSeeText($unpublishedPost->title);
}
Nie musimy sprawdzać w nim wszystkich konfiguracji wartości kolumny published_at
, bo te mamy już sprawdzone w teście jednostkowym modelu Post
. Ten test pomaga nam upewnić się, że w metodzie index()
kontrolera PostController
użyliśmy query scope published
.
W tej drugiej części serii wpisów z tematu "Wprowadzenie do testowania w Laravelu" dowiedzieliśmy się jak automatycznie migrować bazę danych, przed wywołaniem testów oraz jak używać w testach fabryk modeli. Napisaliśmy też test funkcjonalny sprawdzający, że na liście wpisów wyświetlają się tylko te oznaczone jako opublikowane. W celu zapewnienia tej funkcjonalności stworzyliśmy na modelu Post
query scope published
, którego funkcję opisaliśmy najpierw w testach jednostkowych modelu Post
. Przy okazji dowiedzieliśmy się jaka jest różnica między testami funkcjonalnymi a jednostkowymi.
Kod napisany w tym wpisie znajdziecie na GitHubie tu oraz tu.
W następnej części zajmiemy się tematem dodawania wpisów do bazy danych. Napiszemy test sprawdzający, że po wysłaniu formularza w bazie danych pojawia się wpis z podanymi w formularzu danymi. Napiszemy też test, w których sprawdzimy że wpisy moga dodawać tylko zalogowani użytkownicy.
Jeśli macie jakiekolwiek pytania dotyczące wpisu, zapraszamy na naszego Slacka!