Witam w trzeciej części cyklu. Dziś spróbujemy stworzyć model naszego pojazdu, który będziemy wypożyczać. Zdefiniujemy schemat bazy, stworzymy model, wykorzystamy wzorzec Repository a także przekażemy dane do widoku i stworzymy niezbędne testy. Do dzieła!
Zaczynamy od stworzenia katalogu app/models. Będziemy w nim przechowywać nasze pliki modelów. Następnie przenosimy jedyny obecny plik modelu user.php do katalogu models i zmieniamy namespace na namespace app\models.
Kolejna czynność do zaktualizowanie kontrolera Auth/RegisterController, zmieniamy w nim App\User na App\Models\User. Aktualizujemy composer.json:
1 2 3 4 5 6 7 8 9 10 |
"autoload": { "classmap": [ "database" ], "psr-4": { "App\\": "app/", "Models\\": "app/Models" } , |
i wybieramy composer dump-autoload.
Tworzenie modelu
Katalog z modelami stworzony, czas stworzyć nową tabelę pojazdów, w tym celu wybieramy:
1 |
php artisan make:migration create_vehicles_table |
w którego zawartości umieszczamy informacje o naszej nowej tabeli:
- klucz główny id
- kolumnę z opisem, tytułem, adresem obrazu i identyfikatorem użytkownika, do którego będzie dany pojazd należał
- stemple czasowe stworzenia i edycji rekordu (timestamps())
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 27 28 29 30 31 32 33 34 35 |
<?php use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; class CreateVehiclesTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('vehicles', function (Blueprint $table) { $table->increments('id'); $table->string('title'); $table->text('description'); $table->string('image'); $table->integer('user_id'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('vehicles'); } } |
Czas uruchomić tak stworzoną migrację:
1 |
php artisan migrate |
Za pomocą ulubionego interfejsu bazy (phpmyadmin, adminer) dodajemy testowe rekody:
1 2 3 4 |
INSERT INTO `vehicles` (`id`, `title`, `description`, `image`, `user_id`, `created_at`, `updated_at`) VALUES (1, 'Jacht 1 ', 'Opis Jachtu 1 ', '1.jpg', 1, NULL, NULL), (2, 'Jacht 2', 'Opis Jachtu 2 ', '2.jpg', 1, NULL, NULL), (3, 'Jacht 3', 'Opis Jachtu 3 ', '3.jpg', 1, NULL, NULL); |
Czas przenieść pliki graficzne w bardziej odpowiednie miejsce, przenosimy 1.jpg, 2.jpg i 3.jpg do katalogu public/img/vehicles/
Mamy już zdjęcia, mamy rekordy w bazie, czas stworzyć sam model i przenieść go do katalogu models:
php artisan make:model Vehicle
Uzupełniamy plik modelu danymi opisującymi strukturę obiektu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php namespace App\Models\Vehicle; use Illuminate\Database\Eloquent\Model; class Vehicle extends Model { /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'title', 'description', 'image', 'user_id', ]; public function user() { return $this->belongsTo('App\Models\User'); } } |
Ponownie uruchamiamy composer dump-autoload i voila! Mamy nasz pierwszy model 🙂 Czas przejść do kontrolera HomeController:
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 27 28 29 30 31 32 |
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Models\Vehicle; class HomeController extends Controller { /** * Create a new controller instance. * * @return void */ public function __construct() { //$this->middleware('auth'); } /** * Show the application dashboard. * * @return \Illuminate\Http\Response */ public function index(Vehicle $vehicleModel) { $vehicles = $vehicleModel->with('user')->get(); return view('home', ['vehicles' => $vehicles]); } } |
Jak widzimy zmiany dotyczą:
- wstrzyknięcia poprzez Dependency Injection modelu Vehicle do metody index.
- Wywołanie metody get() pobierajacej wszystkie rekordy (z pośrednim użyciem with(‘user’), dzięki czemu tabela Vehicles zostanie złączona z Users i pobraną zostani właściciele pojazdów
- Przekazanie pojazdów do widoku ‘home’Sam widok Home również zostanie zmieniony, zamiast statycznych danych wyświetlmy dynamicznie przekazane dane. W tym celu użyjemy pętli foreach iterującej po wynikach, a następnie odwołując się do poszczególnych pól obiektu wyświetlimy wszystkie niezbędne dane:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<!-- Thumbnails --> @isset($vehicles) <div class="container thumbs"> @foreach ($vehicles as $vehicle) <div class="col-sm-6 col-md-4"> <div class="thumbnail"> <img src="img/vehicles/{{$vehicle->image}}" alt="" class="img-responsive"> <div class="caption"> <h3 class="">{{$vehicle->title}}</h3> <p>{{$vehicle->title}}</p> <p>Właściciel: {{$vehicle->user['name']}}</p> <div class="btn-toolbar text-center"> <a href="/rents/{{$vehicle->id}}" role="button" class="btn btn-primary pull-right">Details</a> </div> </div> </div> </div> @endforeach </div> @endisset <!-- End Thumbnails --> |
Nasze dane dodane do bazy poprawnie wyświetlają się na stronie głównej. Co więcej, dodanie nowego jachtu automatycznie również umieści go na liście.
Repositories
Niby wszystko ok, nasza aplikacja spełnia wszelkie wymagania. Mimo wszystko spróbujmy nieco ją zmodyfikować. Rozważmy sytuację, gdy będziemy chcieli uzyć innego zródła danych niż MySQL – niestety by tego dokonać będziemy musieli sporo kodu mówiąc wprost przepisać. Warto byłoby dodać nową warstwę, która to oddzielałaby wartstwę danych od kontrolera, wszak kontrolera nie obchodzi skąd otrzymuje dane, kontroler potrzebuje jedynie interfejsu do zapisu i pobierania niezbędnych informacji.
W niedawnym wpisie http://blog.pawelkaminski.net/struktura-aplikacji-w-laravel-5/ proponowałem użycie osobnych klas, saverów do zapisu danych. Laravel 5 zachęca do rozbudowy tego podejścia – w tym miejscu pojawia się wzorzec projektowy repository. Jest to dodatkowa warstwa abstrakcji dla naszego modelu, pozwala na dotkliwsze odseparowanie warstwy kontrolera od modelu. W tym celu
- tworzymy katalog app\repository
- dodajemy go w composer.json
- uruchamiamy composer auto-load
Celem użycia wzorca repository jest ustalenie pewnych ram, szablonu, który miałby zastosowanie dla wszystkich rodzajów źródeł danych, a któru ujednolica ich połącznie z kontrolerem. Takim szablonem będzie interfejs, czyli opis podstawowej klasy, zbiór metod, która ów podstawowa klasa musi zapewniać dla naszych obiektów. Widzimy, więc że dowolne reposytorium musi umożliwiać:
- pobranie wszystkich danych z źródła
- stworzenie nowego rekodu danych
- aktualizację istniejącego rekordu
- usunięcie rekordu
App\Repositories\RepositoryInterface.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<?php namespace App\Repositories; interface RepositoryInterface { public function getAll($columns = array('*')); public function create(array $data); public function update(array $data, $id); public function delete($id); public function find($id); } |
Idąc tym tropem dalej, możemy stworzyć podstawową klasę, która będzie implementowała nasz interfejs. Co więcej klasa ta, będzie na tyle elastyczna, iż będzie implementowała podstawową czynności, przy czym nie będzie miała na twardo zapisanego modelu, model będzie przekazywany w konstruktorze, dzięki temu będzie to klasa uniwersalna, nie przypisana na stałe do żadnego z modeli:
App\Repositories\BaseRepository.php
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 27 28 29 30 31 32 33 34 35 36 |
<?php namespace App\Repositories; use App\Repositories\RepositoryInterface; use Illuminate\Database\Eloquent\Model; /** * Class Repository */ abstract class BaseRepository implements RepositoryInterface { /** * @var */ protected $model; public function getAll($columns = array('*')) { return $this->model->get($columns); } public function create(array $data) { return $this->model->create($data); } public function update(array $data, $id) { return $this->model->where("id", '=', $id)->update($data); } public function delete($id) { return $this->model->destroy($id); } public function find($id, $columns = array('*')) { return $this->model->find($id, $columns); } } |
Oba powyższe pliki pozwalają nam na stworzenie konkretnej już klasy repozytorium ‘Vehicle’, która to będzie dziedziczyła z klasy bazowej create, update, delete i find, natomiast będzie nadpisywała metodę getAll (dlatego, iż chcemy oprócz ‘zwykłego’ pobierania wszystkich rekordów z tabeli Vehicles, połączyć to tabelę z tabelą ‘Users’):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php namespace App\Repositories; use App\Models\Vehicle; /** * * @package App\Repository */ class VehicleRepository extends BaseRepository { public function __construct(Vehicle $model) { $this->model = $model; } public function getAll($columns = array('*')) { return $this->model->with('user')->get($columns); } } |
Nasz wzorzec Repository jest już w pełni przygotowany do użycia go w kontrolerze, w którym to już nie odwołujemy się bezpośrednio do modelu, a do VehicleRepository. Obiekt ten wstrzykujemy do metody Index, a następnie uruchamiamy metodę getAll(). Tak jak poprzednio dane przekazujemy do widoku:
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 27 28 29 30 31 32 33 |
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Repositories\VehicleRepository; class HomeController extends Controller { /** * Create a new controller instance. * * @return void */ public function __construct() { //$this->middleware('auth'); } /** * Show the application dashboard. * * @return \Illuminate\Http\Response */ public function index(VehicleRepository $vehicle) { $vehicles = $vehicle->getAll(); return view('home', ['vehicles' => $vehicles]); } } |
Testy
Kolejnym krokiem będzie próba zautomatyzowania testów:
- Kontrolera
Wywołujemy:
php artisan make:test Home/IndexTest.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<?php namespace Tests\Feature\Home; use Tests\TestCase; use Illuminate\Foundation\Testing\WithoutMiddleware; use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseTransactions; class Index extends TestCase { /** * @return void */ public function testIndex() { $mock = \Mockery::mock('App\Repositories\VehicleRepository'); $this->app->instance('App\Repositories\VehicleRepository', $mock); $mock->shouldReceive('getAll')->once(); $response = $this->call('GET', 'home'); $response->assertViewHas('vehicles'); } } |
Co po kolei oznaczają linie kodu? Przede wszystkim:
- tworzymy nową klasę dziedziczącą po TestCase
- tworzymy metodę, która będzie testowała konkretną funkcjonalność, metoda musi rozpoczynać się od słowa test
- kolejnie tworzymy mock klasy VehicleRepository, czyli emuluujemy utworzenie obiektu repozytorium Vehicle
- za pomocą $this->app->instance informujemy Laravela, że w tym aktualnym teście, wszelkie odwołania do App\Repositories\VehicleRepository mają używać mockowanego obiektu $mock (stworzonego linijkę wyżej)
- kolejne linijki to już same testy, za pomocą $mock->shouldReceive(‘getAll’)->once(); stwierdzamy, iż nasz obiekt VehicleRepository powinien wywołać metodę ‘getAll()’ i zrobić to dokładnie jeden raz
- drugi rodzaj testu występuje w kolejnej linijce, wywołujemy tam żądanie GET /home i na końcu za pomocą assertHasView sprawdzamy, czy otrzymana przed chwilą odpowiedź rzeczywiście posiada zmienną ‘vehicles’
Spróbujmy uruchomić testy poprzez wywołanie:
phpunit (lub vendor/bin/phpunit)
Jeśli mamy tylko jeden test, powinniśmy ujrzeć:
Jest ok, widzimy, iż kontroler poprawnie przeszedł testy, odpowiednia metoda została wywołana, sprawdzenie czy do widoku przekazywane są dane również udało się bez przeszkód.
Oprócz tego przydałoby się również jednak sprawdzić same dane. Czy otrzymywana lista jest poprawna? Tu przechodzimy do punktu drugiego:
2. Testów repozytorium
W tym przypadku użyjmy testów jednostkowych, zgodnie z definicją z dokumentacji Laravela:
Unit tests are tests that focus on a very small, isolated portion of your code. In fact, most unit tests probably focus on a single method. Feature tests may test a larger portion of your code, including how several objects interact with each other or even a full HTTP request to a JSON endpoint.
która mówi, iż testy jednostkowe należy wykonywać na małych częsciach chodu, w praktyce są to testy pojedyńczych metod. My właśnie chcemy przetestować pojedyńczą metodę getAll z VehicleRepository. W tym celu wywołujemy:
php artisan make:test Repositories/VehicleRepositoryTest –unit
Do testów przyda nam się również fabryka Vehicle, czyli sposób na tworzenie obiektów testowych. Aby to wykonać należy stworzyć plik VehicleFactory.php w katalogu app\database\factories
Sam plik prezentuje się następująco:
1 2 3 4 5 6 7 8 |
<?php use App\Models\Vehicle; use Faker\Generator; $factory->define(Vehicle::class, function (Generator $faker) { return []; }); |
Podajemy definicję klasy, a także przekazujemy obiekt fakera, czyli Laravelowego generatora losowych danych. My z niego nie skorzystamy, a wszystkie dane do testów wygenerujemy ręcznie.
Przejdźmy do samego pliku testu, tests\Unit\Repositories\VehicleRepositoryTest.php:
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
<?php namespace Tests\Unit\Repositories; use Tests\TestCase; use App\Models\Vehicle; use App\Repositories\VehicleRepository; use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseTransactions; class VehicleRepositoryTest extends TestCase { use DatabaseTransactions; /** * A basic test example. * * @return void */ public function testGetAll() { $data = $this->getAllData(); factory(Vehicle::class)->create($data[0]); factory(Vehicle::class)->create($data[1]); factory(Vehicle::class)->create($data[2]); $all = $this->getFilteredResponse(); $this->assertContains($data[0], $all); $this->assertContains($data[1], $all); $this->assertContains($data[2], $all); } private function getFilteredResponse(){ $vehicleRepository = \App::make(VehicleRepository::class); $all = app()->make(VehicleRepository::class)->getAll()->toArray(); foreach($all as &$single){ unset($single['user']); unset($single['created_at']); unset($single['updated_at']); unset($single['id']); } return $all; } private function getAllData(){ return [ [ "title" => "Vehicle 1", "description" => "Vehicle 1 description", "image" => "Vehicle 1 image", "user_id" => "1" ], [ "title" => "Vehicle 2", "description" => "Vehicle 2 description", "image" => "Vehicle 2 image", "user_id" => "2" ], [ "title" => "Vehicle 3", "description" => "Vehicle 3 description", "image" => "Vehicle 3 image", "user_id" => "3" ] ]; } } |
Przeanalizujmy nasz kod, czego tak właściwie oczekujemy od testów? Na pewno chcielibyśmy dodać do systemu jakieś pojazdy, a następnie sprawdzić, czy system zwróci je nam poprawnie. Po kolei:
1. Tworzymy metodę getAllData, która zwróci nam listę danych testowych
2. Tworzymy metodę getFilteredResponse, metoda ta zwróci nam aktualną listę obiektów w bazie pojazdów. Zauważmy, iż dane są po pobraniu filtrowane, wyrzucam takie rzeczy jak id, created_at, czy user. W celu uproszczenia robię testy bez przynależności pojazdu do konkretnego usera, natomiast pola id czy created są polami, których wartości przed testem po prostu nie znam.
3. Ostatnia metoda to testGetAllData. Zaczyna się od przedrostka test, więc jest to nasza główna metoda testująca. W niej to kolejno: pobieramy statyczne dane testowe, za pomocą factory tworzymy nowe obiekty, wywołujemy wcześniej stworzną metodę getFilteredResponse w celu pobrania listy obiektów, a następnie za pomocą assertContains sprawdzamy, czy pobrana lista zawiera 3 statyczne elementy testowe.
Nie pozostaje nam już nic innego jak uruchomienie testów i radość z pozytywnego ich przejścia:
Nasz system wzbogacił się o możliwość dynamicznego wyświetlania pojazdów. W tym celu:
- Stworzyliśmy model pojazdu
- Stworzyliśmy warstwę danych w postaci repozytorium i jego struktury (interfejsu)
- Dodaliśmy sposób tworzenia obiektu Pojazdu w database\factories
- Dodaliśmy test kontrolera i zwracanych danych
- Dodaliśmy test samych danych – testując repozytorium
Następny etap prac to tabela wypożyczeń.