Testing & TDD
Building a Test Suite - Part 2
Building a Test Suite - Part 2
Continuing from Part 1, we'll now add end-to-end tests, set up continuous integration, generate coverage reports, and document our testing strategy.
End-to-End Tests with Laravel Dusk
Setting Up Dusk
# Install Dusk
composer require --dev laravel/dusk
# Install Dusk in application
php artisan dusk:install
# Install ChromeDriver
php artisan dusk:chrome-driver
# Run Dusk tests
php artisan dusk
Complete Checkout Flow Test
<?php
namespace Tests\Browser;
use App\Models\User;
use App\Models\Product;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class CheckoutFlowTest extends DuskTestCase
{
use DatabaseMigrations;
/** @test */
public function user_can_complete_purchase_flow()
{
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password')
]);
$product = Product::factory()->create([
'name' => 'Test Product',
'price' => 99.99,
'stock' => 10
]);
$this->browse(function (Browser $browser) use ($user, $product) {
$browser
// Step 1: Browse products
->visit('/products')
->assertSee('Test Product')
->assertSee('$99.99')
// Step 2: Add to cart
->click('@add-to-cart-' . $product->id)
->waitForText('Added to cart')
->assertSee('1') // Cart count
// Step 3: View cart
->click('@cart-icon')
->assertPathIs('/cart')
->assertSee('Test Product')
->assertSee('$99.99')
// Step 4: Login to checkout
->press('Proceed to Checkout')
->assertPathIs('/login')
->type('email', 'test@example.com')
->type('password', 'password')
->press('Login')
// Step 5: Fill shipping info
->waitForLocation('/checkout')
->type('address', '123 Test Street')
->type('city', 'Test City')
->type('zip', '12345')
->select('country', 'US')
// Step 6: Enter payment
->press('Continue to Payment')
->waitForText('Payment Information')
->type('card_number', '4242424242424242')
->type('expiry', '12/25')
->type('cvv', '123')
// Step 7: Review & Place Order
->press('Review Order')
->waitForText('Order Summary')
->assertSee('Test Product')
->assertSee('Subtotal: $99.99')
->assertSee('Tax: $15.00')
->assertSee('Total: $114.99')
->press('Place Order')
// Step 8: Order confirmation
->waitForLocation('/orders/confirmation')
->assertSee('Thank you for your order!')
->assertSee('Order #');
// Verify order in database
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'status' => 'paid',
'total' => 114.99
]);
});
}
/** @test */
public function checkout_validates_empty_cart()
{
$user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user) {
$browser
->loginAs($user)
->visit('/cart')
->assertSee('Your cart is empty')
->assertMissing('@checkout-button');
});
}
/** @test */
public function payment_failure_shows_error()
{
$user = User::factory()->create();
$product = Product::factory()->create();
$this->browse(function (Browser $browser) use ($user, $product) {
// Add product and go to payment
$browser
->loginAs($user)
->visit('/products')
->click('@add-to-cart-' . $product->id)
->visit('/checkout')
// ... fill shipping info ...
->type('card_number', '4000000000000002') // Card that will decline
->press('Place Order')
->waitForText('Payment declined')
->assertSee('Please try a different card');
// Order should not be created
$this->assertDatabaseMissing('orders', [
'user_id' => $user->id,
'status' => 'paid'
]);
});
}
}
Continuous Integration Setup
GitHub Actions Workflow
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: test_db
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: xdebug
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Generate key
run: php artisan key:generate
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Run PHPUnit Tests
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: test_db
DB_USERNAME: root
DB_PASSWORD: password
run: php artisan test --coverage --min=80
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: true
GitLab CI Configuration
# .gitlab-ci.yml
image: php:8.2
stages:
- test
- coverage
variables:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
cache:
paths:
- vendor/
before_script:
- apt-get update -yqq
- apt-get install -yqq git libzip-dev unzip
- docker-php-ext-install pdo_mysql zip
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install --no-interaction --prefer-dist
test:unit:
stage: test
script:
- php artisan test --testsuite=Unit --coverage
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test:feature:
stage: test
services:
- mysql:8.0
script:
- cp .env.testing .env
- php artisan migrate --force
- php artisan test --testsuite=Feature
test:browser:
stage: test
before_script:
- apt-get install -y chromium chromium-driver
script:
- php artisan dusk
artifacts:
when: on_failure
paths:
- tests/Browser/screenshots/
- tests/Browser/console/
coverage:
stage: coverage
script:
- php artisan test --coverage-html coverage
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
paths:
- coverage/
Code Coverage Reports
Generating Coverage with PHPUnit
# Generate HTML coverage report
php artisan test --coverage-html coverage
# Generate Clover XML for CI
php artisan test --coverage-clover clover.xml
# Set minimum coverage threshold
php artisan test --min=80
# Coverage for specific directory
php artisan test --coverage --path=app/Services
Reading Coverage Reports
# Open HTML report
open coverage/index.html
# Example coverage output:
Tests: 145 passed
Time: 12.34s
Lines: 87.5% ( 875/1000)
Methods: 92.3% ( 120/130)
Classes: 95.0% ( 19/20)
Coverage Targets:
- Critical code (payment, auth): 95%+ coverage
- Business logic (services, models): 85%+ coverage
- Controllers: 75%+ coverage
- Overall project: 80%+ coverage
Performance Testing
Database Query Performance
<?php
namespace Tests\Performance;
use Tests\TestCase;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
class DatabasePerformanceTest extends TestCase
{
/** @test */
public function product_listing_uses_efficient_queries()
{
Product::factory()->count(100)->create();
DB::enableQueryLog();
$products = Product::with('category')->paginate(20);
$queries = DB::getQueryLog();
// Should only execute 2 queries (products + categories)
$this->assertLessThanOrEqual(2, count($queries));
}
/** @test */
public function order_calculation_completes_quickly()
{
$order = Order::factory()->create();
OrderItem::factory()->count(50)->create([
'order_id' => $order->id
]);
$start = microtime(true);
$total = $order->calculateTotal();
$duration = microtime(true) - $start;
// Should complete in under 100ms
$this->assertLessThan(0.1, $duration);
}
}
API Response Time Testing
<?php
/** @test */
public function api_endpoints_respond_within_acceptable_time()
{
$user = User::factory()->create();
$endpoints = [
'GET /api/products' => 200, // 200ms max
'GET /api/orders' => 300,
'POST /api/cart' => 150,
];
foreach ($endpoints as $endpoint => $maxTime) {
[$method, $url] = explode(' ', $endpoint);
$start = microtime(true);
$this->actingAs($user)->json($method, $url);
$duration = (microtime(true) - $start) * 1000; // Convert to ms
$this->assertLessThan($maxTime, $duration,
"{$endpoint} took {$duration}ms (max: {$maxTime}ms)"
);
}
}
Test Documentation
Creating Test Documentation
# docs/testing-guide.md
# Testing Guide
## Overview
This project maintains a comprehensive test suite covering unit, integration,
and end-to-end tests.
## Running Tests
```bash
# All tests
php artisan test
# Specific suite
php artisan test --testsuite=Unit
php artisan test --testsuite=Feature
# Specific file
php artisan test tests/Unit/Models/ProductTest.php
# With coverage
php artisan test --coverage
# Browser tests
php artisan dusk
```
## Test Structure
```
tests/
├── Unit/ # Isolated unit tests
│ ├── Models/ # Model tests
│ └── Services/ # Service tests
├── Feature/ # HTTP/API tests
│ ├── Auth/ # Authentication
│ └── Api/ # API endpoints
├── Integration/ # Multi-component tests
└── Browser/ # E2E tests with Dusk
```
## Writing Tests
### Unit Tests
Test individual components in isolation:
- Models (attributes, relationships, scopes)
- Services (business logic)
- Helpers (utility functions)
### Feature Tests
Test HTTP requests and responses:
- Authentication flows
- CRUD operations
- API endpoints
- Validation
### Browser Tests
Test complete user journeys:
- Registration & login
- Checkout flow
- Admin workflows
## Coverage Requirements
- Critical features: 95%+
- Business logic: 85%+
- Overall: 80%+
## CI/CD
Tests run automatically on:
- Push to main/develop
- Pull requests
- Pre-deployment
Failures block deployment.
Test Case Template
<?php
namespace Tests\Unit\Models;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Test Suite: [Feature Name]
*
* Purpose: [What this test suite covers]
*
* Coverage:
* - [Scenario 1]
* - [Scenario 2]
*
* Dependencies:
* - [Model/Service/etc.]
*/
class ExampleTest extends TestCase
{
use RefreshDatabase;
/**
* Test: [What behavior is being tested]
*
* Given: [Initial state/context]
* When: [Action performed]
* Then: [Expected outcome]
*
* @test
* @group feature-name
*/
public function descriptive_test_name()
{
// Arrange
// Act
// Assert
}
}
Test Utilities and Helpers
Custom Test Helpers
<?php
namespace Tests;
use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
/**
* Create authenticated user for tests
*/
protected function actingAsUser(array $attributes = []): User
{
$user = User::factory()->create($attributes);
$this->actingAs($user);
return $user;
}
/**
* Create admin user for tests
*/
protected function actingAsAdmin(): User
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
return $admin;
}
/**
* Add products to cart for testing
*/
protected function fillCart(int $productCount = 3): array
{
$products = Product::factory()->count($productCount)->create();
foreach ($products as $product) {
$this->post('/cart', [
'product_id' => $product->id,
'quantity' => 1
]);
}
return $products->toArray();
}
/**
* Assert JSON structure matches expected
*/
protected function assertJsonStructureExact(array $structure, $json): void
{
$data = is_array($json) ? $json : json_decode($json, true);
$this->assertEquals(
array_keys($structure),
array_keys($data),
'JSON structure does not match expected structure'
);
}
}
Monitoring Test Health
Track Test Metrics
# Generate test report
php artisan test --log-junit report.xml
# Example metrics to track:
- Total test count
- Test execution time
- Flaky test rate (tests that intermittently fail)
- Code coverage percentage
- New tests added per sprint
Common Test Suite Problems:
- Slow tests: Suite takes too long to run (optimize or parallelize)
- Flaky tests: Tests fail intermittently (usually timing/async issues)
- Low coverage: Critical code paths untested
- Brittle tests: Break on small changes (testing implementation not behavior)
- Duplicate tests: Same scenario tested multiple times
Final Project:
Complete the e-commerce test suite by adding:
- Browser test: Admin can manage product inventory
- API test: User profile update endpoint
- Integration test: Email notification sent on order completion
- Performance test: Product search completes in under 100ms
- CI/CD: Set up GitHub Actions workflow
- Coverage: Achieve 85%+ coverage
- Documentation: Write testing guide for your team
Summary
Part 2 completed our test suite with:
- E2E Tests: Full user journey testing with Laravel Dusk
- CI/CD: Automated testing on GitHub Actions and GitLab CI
- Coverage Reports: Generated and tracked code coverage metrics
- Performance Tests: Database queries and API response times
- Documentation: Testing guide and templates for the team
- Utilities: Custom helpers to simplify test writing
Maintaining Your Test Suite:
- Run tests before every commit
- Write tests for every bug fix
- Refactor tests when refactoring code
- Review test coverage in pull requests
- Keep tests fast (under 2 minutes for full suite)
- Update documentation when test structure changes