Testing & TDD

Building a Test Suite - Part 2

15 min Lesson 34 of 35

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:

  1. Browser test: Admin can manage product inventory
  2. API test: User profile update endpoint
  3. Integration test: Email notification sent on order completion
  4. Performance test: Product search completes in under 100ms
  5. CI/CD: Set up GitHub Actions workflow
  6. Coverage: Achieve 85%+ coverage
  7. 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