Testowanie to podstawa niezawodnego rozwoju oprogramowania, a Laravel oferuje wyjątkowe ekosystemy testowe. W tym kompleksowym przewodniku omówimy wszystko, co musisz wiedzieć o testowaniu aplikacji Laravel przy użyciu Pest 4, obejmując testy jednostkowe, testy funkcjonalne, testy integracyjne, mockowanie zewnętrznych usług i zaawansowane wzorce testowania.
Spis treści
- Konfiguracja środowiska testowego
- Rodzaje testów
- Podstawy testów jednostkowych
- Testy funkcjonalne w szczegółach
- Testy integracyjne
- Testowanie zewnętrznych usług i API
- Mockowanie i stubowanie z Mockery
- Metody Fake Laravel
- Strategie testowania bazy danych
- Testowanie zadań, kolejek i batchy
- Kompleksowa lista asercji
- Zaawansowane wzorce testowania
- Najlepsze praktyki i wskazówki
Konfiguracja środowiska testowego
Najpierw upewnijmy się, że Twoja aplikacja Laravel 12 jest prawidłowo skonfigurowana do testowania z Pest 4.
Konfiguracja
Zaktualizuj plik phpunit.xml dla Laravel 12:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>
Testowanie równoległe (funkcja Laravel 12)
Laravel 12 wprowadza wbudowane możliwości testowania równoległego. Aby włączyć testowanie równoległe, zainstaluj wymagany pakiet:
composer require brianium/paratest --dev
Następnie uruchom testy równolegle:
# Uruchom testy z automatycznym wykrywaniem procesów
php artisan test --parallel
# Określ liczbę procesów
php artisan test --parallel --processes=4
Podstawowa konfiguracja Pest
Utwórz tests/Pest.php dla Laravel 12 i Pest 4:
<?php
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
// Testy funkcjonalne z odświeżaniem bazy danych
uses(TestCase::class, RefreshDatabase::class)->in('Feature');
// Testy jednostkowe bez bazy danych
uses(TestCase::class)->in('Unit');
// Własne oczekiwania
expect()->extend('toBeValidEmail', function () {
return $this->toBeString()
->and(filter_var($this->value, FILTER_VALIDATE_EMAIL))->not->toBeFalse();
});
expect()->extend('toBeActiveUser', function () {
return $this->toBeInstanceOf(App\Models\User::class)
->and($this->value->is_active)->toBeTrue();
});
// Pomocnicze funkcje testowe
function createUser(array $attributes = []): App\Models\User
{
return App\Models\User::factory()->create($attributes);
}
function authenticateAs(User $user = null): User
{
$user = $user ?? createUser();
test()->actingAs($user);
return $user;
}
Rodzaje testów
Testy jednostkowe
Testują pojedyncze komponenty w izolacji, zazwyczaj pojedyncze metody lub klasy. Te testy są szybkie i nie wymagają zewnętrznych zależności.
Testy funkcjonalne
Testują kompletne funkcjonalności od żądania HTTP do odpowiedzi, w tym middleware, kontrolery i widoki. Te testy symulują rzeczywiste interakcje użytkowników.
Testy integracyjne
Testują, jak różne części Twojej aplikacji współpracują ze sobą, w tym interakcje z bazą danych i zewnętrznymi usługami.
Testy przeglądarkowe (funkcja Pest 4)
Pest 4 wprowadza możliwości testowania przeglądarkowego przy użyciu Playwright, pozwalając na testowanie aplikacji w rzeczywistym środowisku przeglądarki:
# Zainstaluj wtyczkę testowania przeglądarkowego
composer require pestphp/pest-plugin-browser --dev
npm install playwright@latest
npx playwright install
<?php
// tests/Browser/UserRegistrationTest.php
use Pest\Browser\TestCase;
uses(TestCase::class);
it('pozwala na rejestrację użytkownika przez przeglądarkę', function () {
$this->browse(function ($browser) {
$browser->visit('/register')
->type('name', 'Jan Kowalski')
->type('email', '[email protected]')
->type('password', 'hasło')
->type('password_confirmation', 'hasło')
->press('Zarejestruj')
->assertPathIs('/dashboard')
->assertSee('Witaj, Jan Kowalski!');
});
});
Podstawy testów jednostkowych
Testy jednostkowe koncentrują się na testowaniu pojedynczych komponentów w izolacji. Oto jak strukturyzować efektywne testy jednostkowe:
Testowanie klasy serwisowej
<?php
// app/Services/OrderCalculator.php
namespace App\Services;
class OrderCalculator
{
public function calculateTotal(array $items, float $taxRate = 0.1): float
{
$subtotal = array_sum(array_column($items, 'price'));
$tax = $subtotal * $taxRate;
return round($subtotal + $tax, 2);
}
public function calculateDiscount(float $total, string $couponCode): float
{
$discounts = [
'SAVE10' => 0.1,
'SAVE20' => 0.2,
'FIRST50' => 0.5,
];
if (!isset($discounts[$couponCode])) {
return 0;
}
return round($total * $discounts[$couponCode], 2);
}
}
<?php
// tests/Unit/Services/OrderCalculatorTest.php
use App\Services\OrderCalculator;
describe('OrderCalculator', function () {
beforeEach(function () {
$this->calculator = new OrderCalculator();
});
describe('calculateTotal', function () {
it('oblicza całkowitą kwotę z domyślną stawką podatkową', function () {
$items = [
['price' => 10.00],
['price' => 20.00],
['price' => 30.00],
];
$total = $this->calculator->calculateTotal($items);
expect($total)->toBe(66.0); // 60 + 6 (10% podatku)
});
it('oblicza całkowitą kwotę z niestandardową stawką podatkową', function () {
$items = [['price' => 100.00]];
$total = $this->calculator->calculateTotal($items, 0.08);
expect($total)->toBe(108.0);
});
it('obsługuje pustą tablicę elementów', function () {
$total = $this->calculator->calculateTotal([]);
expect($total)->toBe(0.0);
});
});
describe('calculateDiscount', function () {
it('stosuje ważny kod rabatowy', function () {
$discount = $this->calculator->calculateDiscount(100, 'SAVE10');
expect($discount)->toBe(10.0);
});
it('zwraca zero dla nieprawidłowego kodu', function () {
$discount = $this->calculator->calculateDiscount(100, 'INVALID');
expect($discount)->toBe(0.0);
});
});
});
Testowanie modeli
<?php
// tests/Unit/Models/UserTest.php
use App\Models\User;
use App\Models\Post;
describe('User Model', function () {
it('ma wypełnialne atrybuty', function () {
$user = new User();
expect($user->getFillable())->toContain('name', 'email', 'password');
});
it('ukrywa hasło z tablicy', function () {
$user = User::factory()->make();
expect($user->toArray())->not->toHaveKey('password');
});
it('rzutuje email_verified_at na datetime', function () {
$user = User::factory()->make([
'email_verified_at' => '2024-01-01 12:00:00'
]);
expect($user->email_verified_at)->toBeInstanceOf(Carbon\Carbon::class);
});
it('ma relację has many posts', function () {
$user = new User();
expect($user->posts())->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\HasMany::class);
});
});
Testy funkcjonalne w szczegółach
Testy funkcjonalne symulują rzeczywiste interakcje użytkowników z Twoją aplikacją:
Testowanie kontrolerów i tras
<?php
// tests/Feature/PostControllerTest.php
describe('PostController', function () {
beforeEach(function () {
$this->user = User::factory()->create();
$this->post = Post::factory()->create(['user_id' => $this->user->id]);
});
describe('index', function () {
it('wyświetla wszystkie posty', function () {
Post::factory()->count(5)->create();
$response = $this->get('/posts');
$response->assertStatus(200)
->assertViewIs('posts.index')
->assertViewHas('posts');
expect($response->viewData('posts'))->toHaveCount(6); // 5 + 1 z beforeEach
});
it('filtruje posty według kategorii', function () {
Post::factory()->create(['category' => 'tech']);
Post::factory()->create(['category' => 'health']);
$response = $this->get('/posts?category=tech');
$response->assertStatus(200);
$posts = $response->viewData('posts');
expect($posts)->toHaveCount(1);
expect($posts->first()->category)->toBe('tech');
});
});
describe('file upload', function () {
it('obsługuje przesyłanie plików prawidłowo', function () {
Storage::fake('public');
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$response = $this->actingAs(User::factory()->create())
->post('/upload', ['photo' => $file]);
$response->assertStatus(200);
Storage::disk('public')->assertExists('uploads/photo.jpg');
});
it('waliduje typy plików', function () {
Storage::fake('public');
$file = UploadedFile::fake()->create('document.pdf', 100);
$response = $this->actingAs(User::factory()->create())
->post('/upload', ['photo' => $file]);
$response->assertSessionHasErrors(['photo']);
Storage::disk('public')->assertMissing('uploads/document.pdf');
});
it('obsługuje przesyłanie wielu plików', function () {
Storage::fake('public');
$files = [
UploadedFile::fake()->image('photo1.jpg'),
UploadedFile::fake()->image('photo2.jpg'),
UploadedFile::fake()->image('photo3.jpg'),
];
$response = $this->actingAs(User::factory()->create())
->post('/upload-multiple', ['photos' => $files]);
$response->assertStatus(200);
foreach ($files as $index => $file) {
Storage::disk('public')->assertExists("uploads/photo{$index}.jpg");
}
});
});
describe('store', function () {
it('tworzy nowy post dla uwierzytelnionego użytkownika', function () {
$postData = [
'title' => 'Nowy testowy post',
'content' => 'To jest treść testowa',
'category' => 'tech',
];
$response = $this->actingAs($this->user)
->post('/posts', $postData);
$response->assertRedirect('/posts')
->assertSessionHas('success', 'Post utworzony pomyślnie');
$this->assertDatabaseHas('posts', [
'title' => 'Nowy testowy post',
'user_id' => $this->user->id,
]);
});
it('wymaga uwierzytelnienia', function () {
$response = $this->post('/posts', []);
$response->assertRedirect('/login');
});
it('waliduje wymagane pola', function () {
$response = $this->actingAs($this->user)
->post('/posts', []);
$response->assertSessionHasErrors(['title', 'content']);
});
});
describe('update', function () {
it('aktualizuje post dla autoryzowanego użytkownika', function () {
$updateData = [
'title' => 'Zaktualizowany tytuł',
'content' => 'Zaktualizowana treść',
];
$response = $this->actingAs($this->user)
->put("/posts/{$this->post->id}", $updateData);
$response->assertRedirect("/posts/{$this->post->id}");
$this->post->refresh();
expect($this->post->title)->toBe('Zaktualizowany tytuł');
});
it('zapobiega nieautoryzowanym aktualizacjom', function () {
$otherUser = User::factory()->create();
$response = $this->actingAs($otherUser)
->put("/posts/{$this->post->id}", [
'title' => 'Zhackowany tytuł'
]);
$response->assertStatus(403);
});
});
});
Testowanie JSON API
<?php
// tests/Feature/Api/PostApiTest.php
describe('Posts API', function () {
beforeEach(function () {
$this->user = User::factory()->create();
$this->headers = ['Accept' => 'application/json'];
});
describe('GET /api/posts', function () {
it('zwraca posty z paginacją', function () {
Post::factory()->count(15)->create();
$response = $this->getJson('/api/posts');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content', 'created_at']
],
'links',
'meta'
]);
expect($response->json('data'))->toHaveCount(10); // Domyślna paginacja
});
it('obsługuje funkcjonalność wyszukiwania', function () {
Post::factory()->create(['title' => 'Przewodnik testowania Laravel']);
Post::factory()->create(['title' => 'Podstawy Vue.js']);
$response = $this->getJson('/api/posts?search=Laravel');
$response->assertStatus(200);
$posts = $response->json('data');
expect($posts)->toHaveCount(1);
expect($posts[0]['title'])->toBe('Przewodnik testowania Laravel');
});
});
describe('POST /api/posts', function () {
it('tworzy post z prawidłowymi danymi', function () {
$postData = [
'title' => 'Testowy post API',
'content' => 'Treść dla testu API',
'category' => 'tech'
];
$response = $this->actingAs($this->user)
->postJson('/api/posts', $postData);
$response->assertStatus(201)
->assertJsonFragment([
'title' => 'Testowy post API',
'user_id' => $this->user->id
]);
});
it('zwraca błędy walidacji', function () {
$response = $this->actingAs($this->user)
->postJson('/api/posts', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['title', 'content']);
});
});
});
Testy integracyjne
Testy integracyjne weryfikują, że różne komponenty współpracują ze sobą prawidłowo:
Testowanie kompletnych przepływów
<?php
// tests/Feature/OrderWorkflowTest.php
describe('Order Workflow', function () {
beforeEach(function () {
$this->user = User::factory()->create();
$this->product = Product::factory()->create(['price' => 100.00]);
});
it('kończy pełny proces zamówienia', function () {
// Dodaj do koszyka
$this->actingAs($this->user)
->post('/cart/add', [
'product_id' => $this->product->id,
'quantity' => 2
])
->assertStatus(200);
// Weryfikuj zawartość koszyka
$cart = Cart::where('user_id', $this->user->id)->first();
expect($cart->items)->toHaveCount(1);
expect($cart->total)->toBe(200.00);
// Przejdź do checkout
$orderData = [
'shipping_address' => '123 Testowa St',
'billing_address' => '123 Testowa St',
'payment_method' => 'credit_card',
'card_token' => 'test_token_123'
];
$response = $this->actingAs($this->user)
->post('/checkout', $orderData);
$response->assertRedirect('/orders/confirmation');
// Weryfikuj utworzenie zamówienia
$order = Order::where('user_id', $this->user->id)->latest()->first();
expect($order)->not->toBeNull();
expect($order->total)->toBe(200.00);
expect($order->status)->toBe('pending');
// Weryfikuj zmniejszenie zapasów
$this->product->refresh();
expect($this->product->inventory)->toBe($this->product->inventory - 2);
// Weryfikuj wyczyszczenie koszyka
expect(Cart::where('user_id', $this->user->id)->count())->toBe(0);
});
});
Testowanie zewnętrznych usług i API
Podczas testowania zewnętrznych usług musimy mockować żądania HTTP, aby uniknąć wywoływania rzeczywistych API:
Używanie HTTP Fake
<?php
// tests/Feature/ExternalApiTest.php
use Illuminate\Support\Facades\Http;
describe('Integracja z zewnętrznymi API', function () {
describe('WeatherService', function () {
it('pobiera dane pogodowe pomyślnie', function () {
Http::fake([
'api.openweathermap.com/*' => Http::response([
'main' => ['temp' => 22.5],
'weather' => [['description' => 'bezchmurnie']]
], 200)
]);
$service = new App\Services\WeatherService();
$weather = $service->getCurrentWeather('Warszawa');
expect($weather)->toHaveKey('temperature', 22.5);
expect($weather)->toHaveKey('description', 'bezchmurnie');
Http::assertSent(function ($request) {
return $request->url() === 'https://api.openweathermap.org/data/2.5/weather' &&
$request['q'] === 'Warszawa';
});
});
it('obsługuje błędy API elegancko', function () {
Http::fake([
'api.openweathermap.com/*' => Http::response([], 500)
]);
$service = new App\Services\WeatherService();
expect(fn() => $service->getCurrentWeather('Warszawa'))
->toThrow(App\Exceptions\WeatherServiceException::class);
});
it('ponawia nieudane żądania', function () {
Http::fake([
'api.openweathermap.com/*' => Http::sequence()
->push([], 500)
->push([], 500)
->push(['main' => ['temp' => 20]], 200)
]);
$service = new App\Services\WeatherService();
$weather = $service->getCurrentWeather('Warszawa');
expect($weather)->toHaveKey('temperature', 20);
Http::assertSentCount(3);
});
});
describe('PaymentGateway', function () {
it('przetwarza płatność pomyślnie', function () {
Http::fake([
'api.stripe.com/*' => Http::response([
'id' => 'ch_test_123',
'status' => 'succeeded',
'amount' => 5000
], 200)
]);
$gateway = new App\Services\PaymentGateway();
$result = $gateway->charge(50.00, 'card_token_123');
expect($result)->toHaveKey('transaction_id', 'ch_test_123');
expect($result)->toHaveKey('success', true);
});
});
});
Testowanie klienta HTTP Guzzle
<?php
// tests/Unit/Services/ApiClientTest.php
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use App\Services\ApiClient;
describe('ApiClient', function () {
it('wykonuje pomyślne żądanie API', function () {
$mock = new MockHandler([
new Response(200, [], json_encode(['data' => 'sukces']))
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
$apiClient = new ApiClient($client);
$result = $apiClient->fetchData('/endpoint');
expect($result)->toHaveKey('data', 'sukces');
});
it('obsługuje ograniczenia częstotliwości', function () {
$mock = new MockHandler([
new Response(429, ['Retry-After' => 60]),
new Response(200, [], json_encode(['data' => 'sukces']))
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
$apiClient = new ApiClient($client);
expect(fn() => $apiClient->fetchData('/endpoint'))
->toThrow(App\Exceptions\RateLimitException::class);
});
});
Mockowanie i stubowanie z Mockery
Mockery zapewnia potężne możliwości mockowania do testowania złożonych zależności:
Podstawowe mockowanie
<?php
// tests/Unit/Services/OrderServiceTest.php
use Mockery;
use App\Services\OrderService;
use App\Services\PaymentGateway;
use App\Services\InventoryService;
use App\Services\NotificationService;
describe('OrderService', function () {
afterEach(function () {
Mockery::close();
});
it('przetwarza zamówienie pomyślnie', function () {
$paymentGateway = Mockery::mock(PaymentGateway::class);
$inventoryService = Mockery::mock(InventoryService::class);
$notificationService = Mockery::mock(NotificationService::class);
$orderService = new OrderService(
$paymentGateway,
$inventoryService,
$notificationService
);
$orderData = [
'user_id' => 1,
'items' => [['product_id' => 1, 'quantity' => 2]],
'total' => 100.00
];
// Ustaw oczekiwania
$paymentGateway->shouldReceive('charge')
->once()
->with(100.00, 'payment_token')
->andReturn(['success' => true, 'transaction_id' => 'txn_123']);
$inventoryService->shouldReceive('reserveItems')
->once()
->with($orderData['items'])
->andReturn(true);
$notificationService->shouldReceive('sendOrderConfirmation')
->once()
->with(Mockery::type('App\Models\Order'));
$order = $orderService->processOrder($orderData, 'payment_token');
expect($order->status)->toBe('confirmed');
expect($order->transaction_id)->toBe('txn_123');
});
it('obsługuje niepowodzenie płatności', function () {
$paymentGateway = Mockery::mock(PaymentGateway::class);
$inventoryService = Mockery::mock(InventoryService::class);
$notificationService = Mockery::mock(NotificationService::class);
$orderService = new OrderService(
$paymentGateway,
$inventoryService,
$notificationService
);
$paymentGateway->shouldReceive('charge')
->once()
->andReturn(['success' => false, 'error' => 'Karta odrzucona']);
$inventoryService->shouldNotReceive('reserveItems');
$notificationService->shouldNotReceive('sendOrderConfirmation');
expect(fn() => $orderService->processOrder([], 'invalid_token'))
->toThrow(App\Exceptions\PaymentException::class, 'Karta odrzucona');
});
});
Metody Fake Laravel
Laravel zapewnia wygodne metody fake do testowania różnych usług:
Mail Fake
<?php
// tests/Feature/UserRegistrationTest.php
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;
describe('Rejestracja użytkownika', function () {
it('wysyła email powitalny po rejestracji', function () {
Mail::fake();
$userData = [
'name' => 'Jan Kowalski',
'email' => '[email protected]',
'password' => 'hasło',
'password_confirmation' => 'hasło'
];
$response = $this->post('/register', $userData);
$response->assertRedirect('/dashboard');
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($userData) {
return $mail->hasTo($userData['email']) &&
$mail->user->email === $userData['email'];
});
Mail::assertSentCount(1);
});
it('wysyła różne emaile w zależności od typu użytkownika', function () {
Mail::fake();
// Zarejestruj użytkownika admin
$this->post('/register', [
'name' => 'Użytkownik Admin',
'email' => '[email protected]',
'password' => 'hasło',
'password_confirmation' => 'hasło',
'role' => 'admin'
]);
// Zarejestruj zwykłego użytkownika
$this->post('/register', [
'name' => 'Zwykły użytkownik',
'email' => '[email protected]',
'password' => 'hasło',
'password_confirmation' => 'hasło',
]);
Mail::assertSent(App\Mail\AdminWelcomeEmail::class, 1);
Mail::assertSent(WelcomeEmail::class, 1);
Mail::assertSentCount(2);
});
});
Queue Fake
<?php
// tests/Feature/JobDispatchTest.php
use Illuminate\Support\Facades\Queue;
use App\Jobs\ProcessOrder;
use App\Jobs\SendNotification;
describe('Wysyłanie zadań', function () {
it('wysyła zadania prawidłowo', function () {
Queue::fake();
$order = Order::factory()->create();
$this->post("/orders/{$order->id}/process");
Queue::assertPushed(ProcessOrder::class, function ($job) use ($order) {
return $job->order->id === $order->id;
});
Queue::assertPushed(SendNotification::class);
Queue::assertPushedCount(2);
});
it('wysyła zadania do określonych kolejek', function () {
Queue::fake();
$order = Order::factory()->create();
dispatch(new ProcessOrder($order))->onQueue('orders');
dispatch(new SendNotification('Zamówienie przetworzone'))->onQueue('notifications');
Queue::assertPushedOn('orders', ProcessOrder::class);
Queue::assertPushedOn('notifications', SendNotification::class);
});
it('wysyła opóźnione zadania', function () {
Queue::fake();
dispatch(new ProcessOrder(Order::factory()->create()))
->delay(now()->addMinutes(10));
Queue::assertPushed(ProcessOrder::class, function ($job) {
return $job->delay !== null;
});
});
});
Strategie testowania bazy danych
Używanie transakcji
<?php
// tests/Feature/DatabaseTransactionTest.php
use Illuminate\Foundation\Testing\DatabaseTransactions;
class DatabaseTransactionTest extends TestCase
{
use DatabaseTransactions;
public function test_przykład_transakcji_bazy_danych()
{
$user = User::create([
'name' => 'Użytkownik testowy',
'email' => '[email protected]',
'password' => bcrypt('hasło')
]);
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
// Dane zostaną wycofane po teście
}
}
Seedery w testach
<?php
// tests/Feature/DatabaseSeederTest.php
describe('Operacje bazy danych', function () {
it('seeds dane testowe prawidłowo', function () {
$this->seed([
CategorySeeder::class,
ProductSeeder::class,
]);
expect(Category::count())->toBeGreaterThan(0);
expect(Product::count())->toBeGreaterThan(0);
$techCategory = Category::where('name', 'Technologia')->first();
expect($techCategory)->not->toBeNull();
expect($techCategory->products)->toHaveCount(5);
});
it('obsługuje ograniczenia bazy danych', function () {
$category = Category::factory()->create();
$product = Product::factory()->create(['category_id' => $category->id]);
expect(fn() => $category->delete())
->toThrow(Illuminate\Database\QueryException::class);
$product->delete();
$category->delete(); // Teraz to powinno działać
$this->assertDatabaseMissing('categories', ['id' => $category->id]);
});
});
Kompleksowa lista asercji
Asercje specyficzne dla Pest
Pest 4 wprowadza ulepszone możliwości asercji z płynną składnią:
<?php
describe('Asercje Pest', function () {
it('demonstruje podstawowe oczekiwania', function () {
$value = 'Testowanie Laravel';
expect($value)->toBe('Testowanie Laravel')
->toBeString()
->toContain('Testowanie')
->toHaveLength(18)
->not->toBeEmpty();
});
it('demonstruje nowe asercje Pest 4', function () {
$collection = collect([1, 2, 3, 4, 5]);
expect($collection)
->toHaveCount(5)
->toContain(3)
->each->toBeInt()
->and($collection->first())->toBe(1);
// Łączenie z 'and' dla wielu asercji
expect($collection->first())
->toBeInt()
->and($collection->last())->toBe(5);
});
it('demonstruje asercje tablic', function () {
$array = ['name' => 'Jan', 'age' => 30, 'skills' => ['PHP', 'Laravel']];
expect($array)->toHaveKey('name')
->toHaveKey('name', 'Jan')
->toHaveKeys(['name', 'age'])
->toHaveCount(3)
->toMatchArray(['name' => 'Jan', 'age' => 30]);
expect($array['skills'])->toContain('PHP')
->toContainEqual('Laravel')
->each->toBeString();
});
it('demonstruje asercje obiektów', function () {
$user = new User(['name' => 'Jan', 'email' => '[email protected]']);
expect($user)->toBeInstanceOf(User::class)
->toHaveProperty('name')
->toHaveProperty('name', 'Jan')
->toHaveProperties(['name', 'email']);
});
it('demonstruje asercje numeryczne', function () {
$number = 42;
expect($number)->toBe(42)
->toEqual(42)
->toBeInt()
->toBeGreaterThan(40)
->toBeLessThan(50)
->toBeGreaterThanOrEqual(42)
->toBeBetween(40, 45);
});
it('demonstruje asercje boolean i null', function () {
expect(true)->toBeTrue()
->toBeTruthy()
->not->toBeFalse()
->not->toBeFalsy();
expect(null)->toBeNull()
->toBeFalsy();
expect('')->toBeEmpty()
->toBeFalsy();
});
it('demonstruje asercje callable', function () {
$callable = fn() => throw new InvalidArgumentException('Błąd testowy');
expect($callable)->toThrow(InvalidArgumentException::class)
->toThrow(InvalidArgumentException::class, 'Błąd testowy');
$validCallable = fn() => 'sukces';
expect($validCallable)->not->toThrow();
});
});
Asercje specyficzne dla Laravel
<?php
describe('Asercje Laravel', function () {
it('demonstruje asercje odpowiedzi HTTP', function () {
$response = $this->get('/');
$response->assertOk()
->assertStatus(200)
->assertSuccessful()
->assertViewIs('welcome')
->assertViewHas('title')
->assertViewHas('title', 'Witamy')
->assertSee('Witamy w Laravel')
->assertSeeText('Witamy')
->assertDontSee('Błąd')
->assertSeeInOrder(['Witamy', 'Laravel']);
});
it('demonstruje asercje odpowiedzi JSON', function () {
$response = $this->getJson('/api/users');
$response->assertJson(['success' => true])
->assertJsonStructure([
'data' => ['*' => ['id', 'name', 'email']],
'meta' => ['total', 'per_page']
])
->assertJsonFragment(['name' => 'Jan Kowalski'])
->assertJsonMissing(['password'])
->assertJsonCount(10, 'data')
->assertJsonPath('meta.total', 100);
});
it('demonstruje asercje przekierowań', function () {
$response = $this->post('/login', [
'email' => '[email protected]',
'password' => 'hasło'
]);
$response->assertRedirect()
->assertRedirect('/dashboard');
// Testuj określone przekierowania tras
$response = $this->post('/register', [
'name' => 'Jan Kowalski',
'email' => '[email protected]',
'password' => 'hasło',
'password_confirmation' => 'hasło'
]);
$response->assertRedirectToRoute('dashboard');
});
it('demonstruje asercje sesji', function () {
$response = $this->post('/contact', []);
$response->assertSessionHas('message')
->assertSessionHas('message', 'Sukces!')
->assertSessionHasInput('email')
->assertSessionHasErrors(['name', 'email'])
->assertSessionHasErrorsIn('registration', ['email'])
->assertSessionHasNoErrors()
->assertSessionMissing('temp_data');
});
it('demonstruje asercje uwierzytelniania', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->assertAuthenticated()
->assertAuthenticatedAs($user)
->assertGuest('web');
auth()->logout();
$this->assertGuest();
});
it('demonstruje asercje bazy danych', function () {
$user = User::factory()->create(['email' => '[email protected]']);
$this->assertDatabaseHas('users', ['email' => '[email protected]'])
->assertDatabaseMissing('users', ['email' => '[email protected]'])
->assertDatabaseCount('users', 1)
->assertDeleted($user) // Po miękkim usunięciu
->assertSoftDeleted('users', ['id' => $user->id]);
});
it('demonstruje asercje modeli', function () {
$post = Post::factory()->create(['title' => 'Testowy post']);
$user = User::factory()->create();
expect($post)->toExist()
->toBeModel(Post::class)
->toHaveProperty('title', 'Testowy post');
$post->delete();
expect($post)->not->toExist();
});
});
Testowanie wydajności
<?php
// tests/Feature/PerformanceTest.php
describe('Testy wydajności', function () {
it('obsługuje duże zbiory danych efektywnie', function () {
// Utwórz duży zbiór danych
User::factory()->count(1000)->create();
$startTime = microtime(true);
// Zapytanie z paginacją
$users = User::paginate(50);
$endTime = microtime(true);
$executionTime = ($endTime - $startTime) * 1000; // Konwertuj na milisekundy
expect($executionTime)->toBeLessThan(100); // Powinno zakończyć się w ciągu 100ms
expect($users->count())->toBe(50);
});
it('optymalizuje zapytania N+1', function () {
User::factory()
->has(Post::factory()->count(3))
->count(10)
->create();
// Włącz logowanie zapytań
DB::enableQueryLog();
// Pobierz użytkowników z postami (powinno używać eager loading)
$users = User::with('posts')->get();
$queries = DB::getQueryLog();
// Powinno wykonać tylko 2 zapytania: użytkownicy + posty
expect(count($queries))->toBe(2);
expect($users->count())->toBe(10);
expect($users->first()->posts->count())->toBe(3);
});
it('obsługuje użycie pamięci efektywnie', function () {
$startMemory = memory_get_usage();
// Przetwarzaj duże kolekcje w porcjach
User::factory()->count(1000)->create();
User::chunk(100, function ($users) {
foreach ($users as $user) {
// Przetwórz użytkownika
$user->update(['last_processed' => now()]);
}
});
$endMemory = memory_get_usage();
$memoryUsed = $endMemory - $startMemory;
// Powinno używać mniej niż 50MB
expect($memoryUsed)->toBeLessThan(50 * 1024 * 1024);
});
});
Testowanie zadań, kolejek i batchy
Testowanie pojedynczych zadań
<?php
// tests/Unit/Jobs/ProcessOrderJobTest.php
use App\Jobs\ProcessOrder;
use App\Models\Order;
describe('ProcessOrder Job', function () {
it('przetwarza zamówienie pomyślnie', function () {
$order = Order::factory()->create(['status' => 'pending']);
$job = new ProcessOrder($order);
$job->handle();
$order->refresh();
expect($order->status)->toBe('processing');
expect($order->processed_at)->not->toBeNull();
});
it('obsługuje niepowodzenie zadania', function () {
$order = Order::factory()->create(['status' => 'pending']);
// Mock serwisu, który zawiedzie
$mockService = Mockery::mock(App\Services\OrderProcessingService::class);
$mockService->shouldReceive('process')
->once()
->andThrow(new Exception('Przetwarzanie nie powiodło się'));
$this->app->instance(App\Services\OrderProcessingService::class, $mockService);
$job = new ProcessOrder($order);
expect(fn() => $job->handle())->toThrow(Exception::class);
$order->refresh();
expect($order->status)->toBe('pending'); // Status niezmieniony
});
it('ponawia nieudane zadania', function () {
$job = new ProcessOrder(Order::factory()->create());
expect($job->tries)->toBe(3);
expect($job->timeout)->toBe(300);
});
});
Testowanie batchy zadań
<?php
// tests/Feature/BatchProcessingTest.php
use Illuminate\Support\Facades\Bus;
use App\Jobs\ProcessBatchItem;
use App\Jobs\CompleteBatch;
describe('Przetwarzanie batchy', function () {
it('przetwarza batch pomyślnie', function () {
Bus::fake();
$items = collect(range(1, 10))->map(fn($i) => new ProcessBatchItem($i));
$batch = Bus::batch($items)
->then(fn() => dispatch(new CompleteBatch()))
->name('Testowy Batch')
->dispatch();
Bus::assertBatched(function ($batch) {
return $batch->name === 'Testowy Batch' &&
$batch->jobs->count() === 10;
});
});
it('obsługuje anulowanie batchy', function () {
Bus::fake();
$batch = Bus::batch([
new ProcessBatchItem(1),
new ProcessBatchItem(2),
])
->allowFailures()
->dispatch();
$batch->cancel();
expect($batch->cancelled())->toBeTrue();
});
it('przetwarza batch z śledzeniem postępu', function () {
$items = OrderItem::factory()->count(5)->create();
$jobs = $items->map(fn($item) => new ProcessOrderItem($item));
$batch = Bus::batch($jobs)
->progress(function ($batch) {
// Śledź postęp
cache(['batch_progress' => $batch->progress()]);
})
->dispatch();
// Symuluj ukończenie zadania
foreach ($batch->jobs as $job) {
$job->handle();
}
expect(cache('batch_progress'))->toBe(100);
});
});
Testowanie nieudanych zadań
<?php
// tests/Feature/FailedJobTest.php
describe('Obsługa nieudanych zadań', function () {
it('obsługuje niepowodzenia zadań elegancko', function () {
Queue::fake();
$job = new ProcessOrder(Order::factory()->create());
// Symuluj niepowodzenie zadania
$job->fail(new Exception('Nie udało się połączyć z bazą danych'));
Queue::assertPushed(ProcessOrder::class);
// Sprawdź, że niepowodzenie zostało zalogowane
$this->assertDatabaseHas('failed_jobs', [
'payload' => json_encode($job)
]);
});
it('ponawia nieudane zadania z wykładniczym backoff', function () {
$job = new ProcessOrder(Order::factory()->create());
expect($job->backoff())->toEqual([1, 5, 10, 30]);
});
});
Zaawansowane wzorce testowania
Pest 4 zaawansowane funkcje
Zestawy danych testowych z ulepszoną składnią
<?php
// Zaawansowane użycie zestawów danych w Pest 4
describe('Walidacja emaili', function () {
it('waliduje formaty emaili prawidłowo', function (string $email, bool $expected) {
$validator = Validator::make(['email' => $email], [
'email' => 'required|email'
]);
expect($validator->passes())->toBe($expected);
})->with([
['[email protected]', true],
['invalid-email', false],
['[email protected]', true],
['@example.com', false],
]);
// Nazwane zestawy danych dla lepszej czytelności
it('obsługuje różne typy użytkowników', function (string $userType, array $expectedPermissions) {
$user = User::factory()->create(['type' => $userType]);
expect($user->permissions)->toEqual($expectedPermissions);
})->with([
'admin' => ['admin', ['create', 'read', 'update', 'delete']],
'editor' => ['editor', ['create', 'read', 'update']],
'viewer' => ['viewer', ['read']],
]);
});
Grupy testów i organizacja
<?php
// Grupowanie testów dla lepszej organizacji
describe('Zarządzanie użytkownikami', function () {
describe('Uwierzytelnianie', function () {
it('uwierzytelnia prawidłowych użytkowników')
->group('auth')
->group('smoke');
it('odrzuca nieprawidłowe dane uwierzytelniające')
->group('auth')
->group('security');
});
describe('Autoryzacja', function () {
it('pozwala na dostęp admina')
->group('auth')
->group('admin');
it('odmawia dostępu użytkownikom do obszarów admina')
->group('auth')
->group('security');
});
});
// Uruchom określone grupy
// php artisan test --group=smoke
// php artisan test --group=auth,admin
Własne pomocnicze funkcje testowe
<?php
// tests/Support/TestHelpers.php
namespace Tests\Support;
trait DatabaseHelpers
{
protected function seedUsers(int $count = 5): Collection
{
return User::factory()->count($count)->create();
}
protected function createOrderWithItems(int $itemCount = 3): Order
{
$order = Order::factory()->create();
OrderItem::factory()
->count($itemCount)
->create(['order_id' => $order->id]);
return $order;
}
protected function assertDatabaseHasExactCount(string $table, int $count): void
{
$actual = DB::table($table)->count();
expect($actual)->toBe($count, "Oczekiwano {$count} rekordów w {$table}, znaleziono {$actual}");
}
}
// Używanie w testach
uses(DatabaseHelpers::class);
describe('Przetwarzanie zamówień', function () {
it('przetwarza zamówienia z wieloma elementami', function () {
$order = $this->createOrderWithItems(5);
expect($order->items)->toHaveCount(5);
$this->assertDatabaseHasExactCount('orders', 1);
$this->assertDatabaseHasExactCount('order_items', 5);
});
});
Testowanie złożonych przepływów
<?php
// tests/Feature/EcommerceWorkflowTest.php
describe('Przepływ e-commerce', function () {
it('kończy pełny przepływ zakupowy', function () {
// Setup
[$user, $product, $cart] = $this->setupEcommerceScenario();
// Dodaj produkt do koszyka
$this->addToCartAndVerify($user, $product, $cart);
// Zastosuj rabat
$this->applyDiscountAndVerify($cart, 'SAVE10');
// Przetwórz płatność
$order = $this->processPaymentAndVerify($user, $cart);
// Weryfikuj realizację zamówienia
$this->verifyOrderFulfillment($order);
});
private function setupEcommerceScenario(): array
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 100, 'stock' => 10]);
$cart = Cart::factory()->create(['user_id' => $user->id]);
return [$user, $product, $cart];
}
private function addToCartAndVerify(User $user, Product $product, Cart $cart): void
{
$response = $this->actingAs($user)
->post('/cart/add', [
'product_id' => $product->id,
'quantity' => 2
]);
$response->assertOk();
$cart->refresh();
expect($cart->items)->toHaveCount(1);
expect($cart->total)->toBe(200.0);
}
private function applyDiscountAndVerify(Cart $cart, string $coupon): void
{
$response = $this->post('/cart/coupon', ['code' => $coupon]);
$response->assertOk();
$cart->refresh();
expect($cart->discount_amount)->toBe(20.0); // 10% z 200
expect($cart->final_total)->toBe(180.0);
}
private function processPaymentAndVerify(User $user, Cart $cart): Order
{
Http::fake([
'api.stripe.com/*' => Http::response([
'id' => 'ch_test_123',
'status' => 'succeeded'
])
]);
$response = $this->actingAs($user)
->post('/checkout', [
'payment_method' => 'card_token_123',
'billing_address' => '123 Testowa St'
]);
$response->assertRedirect('/orders/confirmation');
$order = Order::where('user_id', $user->id)->latest()->first();
expect($order)->not->toBeNull();
expect($order->total)->toBe(180.0);
expect($order->status)->toBe('paid');
return $order;
}
private function verifyOrderFulfillment(Order $order): void
{
Queue::fake();
event(new OrderPaid($order));
Queue::assertPushed(ProcessOrderFulfillment::class);
Queue::assertPushed(SendOrderConfirmationEmail::class);
}
});
Testowanie z manipulacją czasu
<?php
// tests/Feature/TimeBasedTest.php
use Illuminate\Support\Facades\Date;
describe('Funkcje zależne od czasu', function () {
it('obsługuje wygaśnięcie subskrypcji', function () {
// Przejdź do określonej daty
$this->travel('2024-01-01');
$user = User::factory()->create();
$subscription = Subscription::factory()->create([
'user_id' => $user->id,
'expires_at' => now()->addDays(30)
]);
expect($subscription->isActive())->toBeTrue();
// Przejdź do daty wygaśnięcia
$this->travelTo($subscription->expires_at->addDay());
expect($subscription->fresh()->isActive())->toBeFalse();
});
it('obsługuje planowanie zadań dziennych', function () {
Queue::fake();
$this->travel('2024-01-01 09:00:00');
// Uruchom dzienne zadanie czyszczenia
Artisan::call('schedule:run');
Queue::assertPushed(DailyCleanupJob::class);
// Przejdź do przodu o 1 dzień
$this->travel(24 * 60 * 60); // 24 godziny w sekundach
Artisan::call('schedule:run');
Queue::assertPushedTimes(DailyCleanupJob::class, 2);
});
it('obsługuje konwersje stref czasowych', function () {
$user = User::factory()->create(['timezone' => 'Europe/Warsaw']);
// Ustaw strefę czasową aplikacji na UTC
config(['app.timezone' => 'UTC']);
$this->travelTo('2024-01-01 12:00:00 UTC');
$event = Event::factory()->create([
'starts_at' => now()->setTimezone($user->timezone)
]);
expect($event->starts_at->format('H:i'))->toBe('13:00'); // 1 godzina różnicy
});
});
Testowanie operacji współbieżnych
<?php
// tests/Feature/ConcurrencyTest.php
describe('Obsługa współbieżności', function () {
it('obsługuje warunki wyścigu w zapasach', function () {
$product = Product::factory()->create(['stock' => 1]);
// Symuluj dwie współbieżne próby zakupu
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Mock współbieżnych żądań
$responses = collect([
$this->actingAs($user1)->post('/purchase', ['product_id' => $product->id]),
$this->actingAs($user2)->post('/purchase', ['product_id' => $product->id])
]);
// Jeden powinien się powieść, jeden zawieść
$successes = $responses->filter(fn($r) => $r->isRedirect())->count();
$failures = $responses->filter(fn($r) => $r->status() === 409)->count();
expect($successes)->toBe(1);
expect($failures)->toBe(1);
$product->refresh();
expect($product->stock)->toBe(0);
});
it('obsługuje blokady bazy danych prawidłowo', function () {
$account = Account::factory()->create(['balance' => 1000]);
// Symuluj współbieżne wypłaty
DB::transaction(function () use ($account) {
$lockedAccount = Account::lockForUpdate()->find($account->id);
if ($lockedAccount->balance >= 500) {
$lockedAccount->decrement('balance', 500);
}
});
$account->refresh();
expect($account->balance)->toBe(500);
});
});
Najlepsze praktyki i wskazówki
Strategie testowe na poziomie seniora
Wzorce architektury testów
<?php
// tests/Feature/OrderWorkflowTest.php
class OrderWorkflowTest extends TestCase
{
use RefreshDatabase;
private User $customer;
private Product $product;
private Cart $cart;
protected function setUp(): void
{
parent::setUp();
// Arrange: Ustaw dane testowe
$this->customer = $this->createVerifiedCustomer();
$this->product = $this->createProductWithStock(10);
$this->cart = $this->createEmptyCart($this->customer);
}
/**
* @test
* @group ecommerce
* @group integration
*/
public function it_completes_full_purchase_workflow(): void
{
// Act: Wykonaj przepływ
$this->addProductToCart($this->product, 2);
$this->applyDiscount('SAVE10');
$order = $this->processPayment();
// Assert: Weryfikuj wynik
$this->assertOrderCreated($order);
$this->assertInventoryReduced($this->product, 2);
$this->assertCartCleared($this->customer);
$this->assertNotificationsSent($order);
}
private function createVerifiedCustomer(): User
{
return User::factory()
->verified()
->withProfile()
->create();
}
private function assertOrderCreated(Order $order): void
{
expect($order)
->toBeInstanceOf(Order::class)
->and($order->status)->toBe(OrderStatus::CONFIRMED)
->and($order->total)->toBeGreaterThan(0);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $this->customer->id,
'status' => OrderStatus::CONFIRMED->value,
]);
}
}
Struktura testów oparta na domenie
<?php
// tests/Domain/Order/OrderAggregateTest.php
namespace Tests\Domain\Order;
describe('Order Aggregate', function () {
beforeEach(function () {
$this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->paymentService = Mockery::mock(PaymentServiceInterface::class);
$this->inventoryService = Mockery::mock(InventoryServiceInterface::class);
$this->orderService = new OrderService(
$this->orderRepository,
$this->paymentService,
$this->inventoryService
);
});
describe('Tworzenie zamówienia', function () {
it('tworzy zamówienie z prawidłowymi elementami', function () {
// Arrange
$customer = Customer::factory()->create();
$items = OrderItem::factory()->count(3)->make();
$this->inventoryService
->shouldReceive('reserveItems')
->once()
->with($items->toArray())
->andReturn(true);
$this->orderRepository
->shouldReceive('save')
->once()
->with(Mockery::type(Order::class));
// Act
$order = $this->orderService->createOrder($customer, $items);
// Assert
expect($order)
->toBeInstanceOf(Order::class)
->and($order->customer_id)->toBe($customer->id)
->and($order->items)->toHaveCount(3)
->and($order->status)->toBe(OrderStatus::PENDING);
});
});
});
Testowanie kontraktów
<?php
// tests/Contracts/PaymentGatewayContractTest.php
describe('Kontrakt PaymentGateway', function () {
it('spełnia interfejs payment gateway', function (PaymentGatewayInterface $gateway) {
$paymentRequest = new PaymentRequest(
amount: Money::PLN(10000), // 100.00 PLN
token: 'tok_test_123',
metadata: ['order_id' => '123']
);
$result = $gateway->charge($paymentRequest);
expect($result)
->toBeInstanceOf(PaymentResult::class)
->and($result->success)->toBeBool()
->and($result->transactionId)->toBeString()
->and($result->amount)->toBeInstanceOf(Money::class);
})->with([
fn() => app(StripePaymentGateway::class),
fn() => app(PayPalPaymentGateway::class),
fn() => app(MockPaymentGateway::class),
]);
});
Organizacja testów
<?php
// Używaj opisowych nazw testów
describe('Rejestracja użytkownika', function () {
describe('gdy podano prawidłowe dane', function () {
it('tworzy nowe konto użytkownika', function () {
// Implementacja testu
});
it('wysyła email powitalny', function () {
// Implementacja testu
});
it('przekierowuje do pulpitu', function () {
// Implementacja testu
});
});
describe('gdy podano nieprawidłowe dane', function () {
it('zwraca błędy walidacji dla brakujących pól', function () {
// Implementacja testu
});
it('zwraca błąd walidacji dla zduplikowanego emaila', function () {
// Implementacja testu
});
});
});
Używanie dostawców danych
<?php
describe('Testy walidacji', function () {
it('waliduje formaty emaili', function (string $email, bool $shouldBeValid) {
$validator = Validator::make(['email' => $email], ['email' => 'email']);
expect($validator->passes())->toBe($shouldBeValid);
})->with([
['[email protected]', true],
['invalid-email', false],
['test@', false],
['@example.com', false],
['test@example', false],
]);
it('waliduje siłę hasła', function (string $password, bool $shouldBeValid) {
$validator = Validator::make(['password' => $password], [
'password' => 'min:8|regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/'
]);
expect($validator->passes())->toBe($shouldBeValid);
})->with([
['Hasło123', true],
['hasło123', false], // Brak wielkich liter
['HASŁO123', false], // Brak małych liter
['Hasło', false], // Brak cyfr
['Has1', false], // Za krótkie
]);
});
Współdzielone dane testowe
<?php
// tests/Support/TestDataFactory.php
class TestDataFactory
{
public static function createEcommerceScenario(): array
{
$category = Category::factory()->create(['name' => 'Elektronika']);
$product = Product::factory()->create([
'category_id' => $category->id,
'price' => 299.99,
'stock' => 50
]);
$user = User::factory()->create(['email_verified_at' => now()]);
return compact('category', 'product', 'user');
}
public static function createOrderWithItems(int $itemCount = 3): Order
{
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
OrderItem::factory()
->count($itemCount)
->create(['order_id' => $order->id]);
return $order;
}
}
// Używanie w testach
describe('Przetwarzanie zamówień', function () {
it('przetwarza zamówienie z wieloma elementami', function () {
$order = TestDataFactory::createOrderWithItems(5);
expect($order->items)->toHaveCount(5);
expect($order->total)->toBeGreaterThan(0);
});
});
Pomocnicze funkcje testowe i makra
<?php
// tests/Support/TestHelpers.php
trait TestHelpers
{
protected function authenticateUser(?User $user = null): User
{
$user = $user ?: User::factory()->create();
$this->actingAs($user);
return $user;
}
protected function authenticateAdmin(): User
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
return $admin;
}
protected function createProductWithStock(int $stock = 10): Product
{
return Product::factory()->create(['stock' => $stock]);
}
protected function assertDatabaseHasCount(string $table, int $count): void
{
$actual = DB::table($table)->count();
$this->assertEquals($count, $actual, "Oczekiwano {$count} rekordów w {$table}, znaleziono {$actual}");
}
protected function assertJobWasDispatched(string $jobClass, ?callable $callback = null): void
{
Queue::assertPushed($jobClass, $callback);
}
}
// Używanie w bazowej klasie testowej
// tests/TestCase.php
abstract class TestCase extends BaseTestCase
{
use CreatesApplication, TestHelpers;
}
// tests/Feature/ProductTest.php
describe('Zarządzanie produktami', function () {
it('tworzy produkt pomyślnie', function () {
$admin = $this->authenticateAdmin();
$product = $this->createProductWithStock(20);
expect($product->stock)->toBe(20);
$this->assertDatabaseHasCount('products', 1);
});
});
Testowanie specyficzne dla środowiska
<?php
// tests/Feature/EnvironmentTest.php
describe('Funkcje specyficzne dla środowiska', function () {
it('obsługuje logikę specyficzną dla produkcji', function () {
app()->detectEnvironment(fn() => 'production');
$service = new App\Services\CacheService();
// W produkcji cache powinien być używany
expect($service->shouldUseCache())->toBeTrue();
});
it('obsługuje logikę specyficzną dla rozwoju', function () {
app()->detectEnvironment(fn() => 'local');
$service = new App\Services\CacheService();
// W rozwoju cache może być wyłączony
expect($service->shouldUseCache())->toBeFalse();
});
it('obsługuje środowisko testowe prawidłowo', function () {
expect(app()->environment())->toBe('testing');
expect(config('app.debug'))->toBeTrue();
});
});
Testowanie konfiguracji
<?php
// tests/Feature/ConfigurationTest.php
describe('Konfiguracja aplikacji', function () {
it('ma prawidłową konfigurację bazy danych dla testów', function () {
expect(config('database.default'))->toBe('sqlite');
expect(config('database.connections.sqlite.database'))->toBe(':memory:');
expect(config('database.connections.sqlite.foreign_key_constraints'))->toBeTrue();
});
it('ma prawidłową konfigurację maili dla testów', function () {
expect(config('mail.default'))->toBe('array');
});
it('ma prawidłową konfigurację kolejek dla testów', function () {
expect(config('queue.default'))->toBe('sync');
});
it('może nadpisać konfigurację w testach', function () {
config(['services.stripe.key' => 'test_key_123']);
expect(config('services.stripe.key'))->toBe('test_key_123');
});
});
Testowanie middleware
<?php
// tests/Feature/MiddlewareTest.php
describe('Middleware', function () {
describe('Middleware uwierzytelniania', function () {
it('pozwala uwierzytelnionym użytkownikom', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
});
it('przekierowuje gości do logowania', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});
});
describe('Middleware Admin', function () {
it('pozwala użytkownikom admin', function () {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)->get('/admin/dashboard');
$response->assertOk();
});
it('odmawia użytkownikom nie-admin', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/admin/dashboard');
$response->assertStatus(403);
});
});
describe('Middleware ograniczenia częstotliwości', function () {
it('pozwala na żądania w limicie', function () {
for ($i = 0; $i < 10; $i++) {
$response = $this->get('/api/limited-endpoint');
$response->assertOk();
}
});
it('blokuje żądania przekraczające limit', function () {
// Wykonaj 60 żądań (zakładając limit 60 na minutę)
for ($i = 0; $i < 61; $i++) {
$response = $this->get('/api/limited-endpoint');
}
$response->assertStatus(429); // Too Many Requests
});
});
});
Testowanie reguł walidacji
<?php
// tests/Feature/ValidationTest.php
describe('Własne reguły walidacji', function () {
it('waliduje własne reguły prawidłowo', function () {
$validator = Validator::make([
'username' => 'test_user_123'
], [
'username' => [new App\Rules\AlphanumericUnderscore]
]);
expect($validator->passes())->toBeTrue();
});
it('nie udaje się walidacji dla nieprawidłowych własnych reguł', function () {
$validator = Validator::make([
'username' => 'test-user-123'
], [
'username' => [new App\Rules\AlphanumericUnderscore]
]);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->first('username'))
->toContain('może zawierać tylko litery, cyfry i podkreślenia');
});
});
Testowanie komend Artisan
<?php
// tests/Feature/CommandTest.php
describe('Komendy Artisan', function () {
it('uruchamia komendę czyszczenia pomyślnie', function () {
// Utwórz stare dane
User::factory()->create(['created_at' => now()->subDays(400)]);
User::factory()->create(['created_at' => now()->subDays(200)]);
$this->artisan('app:cleanup-old-users --days=365')
->expectsOutput('Czyszczenie użytkowników starszych niż 365 dni...')
->expectsOutput('Usunięto 1 starych użytkowników')
->assertExitCode(0);
expect(User::count())->toBe(1);
});
it('obsługuje opcje komend prawidłowo', function () {
User::factory()->count(5)->create(['created_at' => now()->subDays(400)]);
$this->artisan('app:cleanup-old-users', [
'--days' => 365,
'--dry-run' => true
])
->expectsOutput('Tryb dry run: Usunięto by 5 użytkowników')
->assertExitCode(0);
// W trybie dry run nie powinni być usunięci żadni użytkownicy
expect(User::count())->toBe(5);
});
it('waliduje argumenty komend', function () {
$this->artisan('app:cleanup-old-users --days=invalid')
->expectsOutput('Błąd: Dni muszą być prawidłową liczbą')
->assertExitCode(1);
});
});
Testowanie obserwatorów i zdarzeń
<?php
// tests/Feature/ObserverTest.php
describe('Obserwatory modeli', function () {
it('wywołuje metody obserwatora na zdarzeniach modelu', function () {
Event::fake();
$user = User::create([
'name' => 'Jan Kowalski',
'email' => '[email protected]',
'password' => bcrypt('hasło')
]);
Event::assertDispatched(Registered::class, function ($event) use ($user) {
return $event->user->id === $user->id;
});
});
it('obsługuje usuwanie modelu prawidłowo', function () {
Mail::fake();
$user = User::factory()->create();
$user->delete();
Mail::assertSent(AccountDeletionNotification::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
});
});
Testowanie operacji na plikach
<?php
// tests/Feature/FileOperationTest.php
describe('Operacje na plikach', function () {
it('przetwarza pliki CSV prawidłowo', function () {
Storage::fake('local');
$csvContent = "name,email\nJan Kowalski,[email protected]\nAnna Kowalska,[email protected]";
Storage::disk('local')->put('users.csv', $csvContent);
$importer = new App\Services\UserImporter();
$result = $importer->importFromCsv(storage_path('app/users.csv'));
expect($result['imported'])->toBe(2);
expect(User::count())->toBe(2);
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
it('obsługuje źle sformatowane pliki CSV', function () {
Storage::fake('local');
$malformedCsv = "name,email\nJan Kowalski\nAnna Kowalska,[email protected]";
Storage::disk('local')->put('malformed.csv', $malformedCsv);
$importer = new App\Services\UserImporter();
expect(fn() => $importer->importFromCsv(storage_path('app/malformed.csv')))
->toThrow(InvalidArgumentException::class);
});
});
Wskazówki dotyczące testowania wydajności
<?php
// tests/Feature/PerformanceOptimizationTest.php
describe('Optymalizacje wydajności', function () {
it('używa indeksów bazy danych efektywnie', function () {
// Utwórz dane testowe
User::factory()->count(1000)->create();
DB::enableQueryLog();
// To zapytanie powinno używać indeksu
$users = User::where('email', '[email protected]')->get();
$queries = DB::getQueryLog();
$lastQuery = end($queries);
// Sprawdź, że czas wykonania zapytania jest rozsądny
expect($lastQuery['time'])->toBeLessThan(50); // milisekundy
});
it('implementuje prawidłową strategię cacheowania', function () {
Cache::flush();
$service = new App\Services\StatisticsService();
// Pierwsze wywołanie powinno trafić do bazy danych
$startTime = microtime(true);
$stats1 = $service->getMonthlyStats();
$firstCallTime = (microtime(true) - $startTime) * 1000;
// Drugie wywołanie powinno trafić do cache
$startTime = microtime(true);
$stats2 = $service->getMonthlyStats();
$secondCallTime = (microtime(true) - $startTime) * 1000;
expect($stats1)->toEqual($stats2);
expect($secondCallTime)->toBeLessThan($firstCallTime / 2);
});
});
Testowanie obsługi błędów
<?php
// tests/Feature/ErrorHandlingTest.php
describe('Obsługa błędów', function () {
it('obsługuje błędy połączenia z bazą danych elegancko', function () {
// Tymczasowo przerwij połączenie z bazą danych
config(['database.connections.sqlite.database' => '/nonexistent/path']);
DB::purge('sqlite');
$response = $this->get('/dashboard');
$response->assertStatus(500);
$response->assertSee('Usługa tymczasowo niedostępna');
});
it('obsługuje niepowodzenia zewnętrznych API', function () {
Http::fake(['*' => Http::response([], 500)]);
$service = new App\Services\WeatherService();
expect(fn() => $service->getCurrentWeather('Warszawa'))
->toThrow(App\Exceptions\ExternalServiceException::class);
});
it('loguje błędy odpowiednio', function () {
Log::fake();
try {
throw new Exception('Błąd testowy do logowania');
} catch (Exception $e) {
Log::error('Wystąpił błąd testowy', ['exception' => $e]);
}
Log::assertLogged('error', function ($message, $context) {
return str_contains($message, 'Wystąpił błąd testowy') &&
isset($context['exception']);
});
});
});
Podsumowanie
Ten kompleksowy przewodnik demonstruje profesjonalne praktyki testowe dla aplikacji Laravel 12 używających Pest 4. Strategie przedstawione tutaj są zaprojektowane dla senior developerów, którzy rozumieją, że testowanie to nie tylko praktyka programistyczna, ale fundamentalny aspekt architektury oprogramowania.
Zasady architektury testowej
Zaawansowane strategie testowe
- Testowanie oparte na domenie: Strukturyzuj testy wokół domen biznesowych, nie warstw technicznych
- Rozwój oparty na zachowaniu: Pisz testy, które opisują zachowanie użytkowników i wyniki biznesowe
- Testowanie oparte na właściwościach: Używaj generatorów danych do automatycznego testowania przypadków brzegowych
- Testowanie mutacyjne: Weryfikuj jakość testów przez wprowadzanie kontrolowanych błędów
- Testowanie wydajności: Uwzględniaj asercje wydajności w testach funkcjonalnych
Najlepsze praktyki
Standardy jakości kodu
- Nazewnictwo testów: Używaj nazewnictwa opartego na zachowaniu, które opisuje wyniki biznesowe
- Struktura testów: Podążaj za wzorcem Given-When-Then dla przejrzystości
- Strategia mockowania: Mockuj na granicach, nie w obrębie swojej domeny
- Zarządzanie danymi: Używaj fabryk z domyślnymi wartościami i realistycznymi danymi
- Testowanie błędów: Testuj zarówno ścieżki sukcesu, jak i wszystkie scenariusze niepowodzenia
- Wydajność: Uwzględniaj asercje wydajności i użycia pamięci
Zaawansowane wzorce dla aplikacji enterprise
- Testowe podwójne: Używaj odpowiednio szpiegów, stubów i mocków dla różnych scenariuszy
- Manipulacja czasu: Testuj komprehensywnie logikę biznesową zależną od czasu
- Testowanie współbieżności: Weryfikuj bezpieczeństwo wątków i obsługę warunków wyścigu
- Transakcje bazy danych: Używaj odpowiednich poziomów izolacji dla różnych typów testów
- Testowanie zewnętrznych usług: Implementuj komprehensywne testowanie kontraktów
- Testowanie bezpieczeństwa: Uwzględniaj testy uwierzytelniania, autoryzacji i ochrony danych
Kluczowe narzędzia i technologie
Rdzeń frameworka testowego
- Pest 4: Nowoczesny framework testowy z ulepszonymi możliwościami asercji i testowaniem przeglądarkowym
- Laravel Testing: Wbudowane narzędzia testowe zoptymalizowane dla Laravel 12
- PHPUnit: Podstawowy framework testowy z zaawansowanymi funkcjami
Mockowanie i stubowanie
- Mockery: Zaawansowane możliwości mockowania dla złożonych scenariuszy
- Laravel Fakes: Wbudowane fałszywe dla usług Laravel (Mail, Queue, Storage, itp.)
- HTTP Fakes: Komprehensywne testowanie klienta HTTP
Zaawansowane narzędzia testowe
- Testowanie równoległe: Wbudowane równoległe wykonywanie testów w Laravel 12
- Testowanie przeglądarkowe: Integracja Playwright z Pest 4 do testowania end-to-end
- Testowanie bazy danych: Bazy danych w pamięci i wycofywanie transakcji
- Testowanie wydajności: Asercje pamięci i czasu wykonania
Mierzenie sukcesu
Metryki pokrycia testowego
- Pokrycie linii: Minimum 80% dla logiki biznesowej, 90% dla krytycznych ścieżek
- Pokrycie gałęzi: Minimum 70% dla złożonych drzew decyzyjnych
- Wynik mutacyjny: Minimum 80% do zapewnienia jakości testów
- Wydajność: Wszystkie testy kończą się w akceptowalnych limitach czasu
Wskaźniki jakości
- Czas wykonania testów: Testy funkcjonalne kończą się w ciągu 30 sekund
- Niezawodność testów: Mniej niż 1% flaky test rate
- Wykrywanie błędów: Testy łapią 95% błędów przed produkcją
- Bezpieczeństwo refaktoryzacji: Pewna refaktoryzacja z komprehensywnym pokryciem testowym
Ostatnie przemyślenia
Pamiętaj:
- Testy to dokumentacja, która nigdy nie wychodzi z mody
- Testowanie napędza lepszy design, zmuszając do myślenia o interfejsach i zależnościach
- Komprehensywne testowanie umożliwia pewną refaktoryzację i ciągłe ulepszanie
- Jakościowe testowanie redukuje dług techniczny i przyspiesza prędkość rozwoju
Śledź mnie na LinkedIn po więcej porad dotyczących DevOps i Laravel!
Chcesz dowiedzieć się więcej o testach w Laravel? Daj mi znać w komentarzach poniżej!