Testing & TDD

Building a Test Suite - Part 1

15 min Lesson 33 of 35

Building a Test Suite - Part 1

In this two-part lesson, we'll build a comprehensive test suite for a real-world e-commerce application. Part 1 covers planning, project setup, unit tests, and integration tests.

Project Overview: E-Commerce Platform

We're building tests for an online store with these features:

  • User authentication and authorization
  • Product catalog with categories
  • Shopping cart functionality
  • Order processing and payment
  • Email notifications
  • Admin panel for inventory management

Test Strategy Planning

Testing Pyramid

/\ / \ E2E Tests (10%) / \ - Critical user journeys /------\ - Full checkout flow / \ / Integ. \ Integration Tests (20%) / \ - API endpoints /--------------\ - External services / \ / Unit Tests \ Unit Tests (70%) / \ - Models, services /______________________\ - Helpers, utilities
Test Distribution Strategy:
  • 70% Unit Tests: Fast, isolated, test individual components
  • 20% Integration Tests: Test component interactions
  • 10% E2E Tests: Test critical user journeys end-to-end

What to Test

HIGH PRIORITY (Must Test): ✓ User authentication & authorization ✓ Payment processing ✓ Order creation & fulfillment ✓ Cart calculations (totals, tax, discounts) ✓ Inventory management ✓ Security (XSS, CSRF, SQL injection) MEDIUM PRIORITY (Should Test): ✓ Product search & filtering ✓ Email notifications ✓ User profile management ✓ Admin CRUD operations LOW PRIORITY (Nice to Test): ✓ UI components ✓ Formatting helpers ✓ Static content pages

Project Setup

Installing Testing Tools

# Core testing composer require --dev phpunit/phpunit # Laravel testing helpers composer require --dev laravel/dusk # Mocking composer require --dev mockery/mockery # API testing composer require --dev pestphp/pest pestphp/pest-plugin-laravel # Database composer require --dev doctrine/dbal # Code coverage composer require --dev phpunit/php-code-coverage

PHPUnit Configuration

<!-- phpunit.xml --> <?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" colors="true" failOnRisky="true" failOnWarning="true" stopOnFailure="false"> <testsuites> <testsuite name="Unit"> <directory suffix="Test.php">./tests/Unit</directory> </testsuite> <testsuite name="Feature"> <directory suffix="Test.php">./tests/Feature</directory> </testsuite> </testsuites> <coverage processUncoveredFiles="true"> <include> <directory suffix=".php">./app</directory> </include> <exclude> <directory>./app/Console</directory> <directory>./app/Exceptions</directory> </exclude> </coverage> <php> <env name="APP_ENV" value="testing"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="CACHE_DRIVER" value="array"/> <env name="QUEUE_CONNECTION" value="sync"/> <env name="MAIL_MAILER" value="array"/> </php> </phpunit>

Unit Tests: Models

Product Model Tests

<?php namespace Tests\Unit\Models; use Tests\TestCase; use App\Models\Product; use App\Models\Category; use Illuminate\Foundation\Testing\RefreshDatabase; class ProductTest extends TestCase { use RefreshDatabase; /** @test */ public function product_has_name_and_price() { $product = Product::factory()->create([ 'name' => 'Laptop', 'price' => 999.99 ]); $this->assertEquals('Laptop', $product->name); $this->assertEquals(999.99, $product->price); } /** @test */ public function product_belongs_to_category() { $category = Category::factory()->create(); $product = Product::factory()->create([ 'category_id' => $category->id ]); $this->assertInstanceOf(Category::class, $product->category); $this->assertEquals($category->id, $product->category->id); } /** @test */ public function product_has_formatted_price_attribute() { $product = Product::factory()->create([ 'price' => 1234.56 ]); $this->assertEquals('$1,234.56', $product->formatted_price); } /** @test */ public function product_can_be_marked_as_out_of_stock() { $product = Product::factory()->create([ 'stock' => 10 ]); $this->assertTrue($product->isInStock()); $product->update(['stock' => 0]); $this->assertFalse($product->fresh()->isInStock()); } /** @test */ public function product_can_apply_discount() { $product = Product::factory()->create([ 'price' => 100 ]); $discountedPrice = $product->applyDiscount(0.20); // 20% off $this->assertEquals(80, $discountedPrice); $this->assertEquals(100, $product->price); // Original price unchanged } /** @test */ public function product_scope_filters_active_products() { Product::factory()->count(3)->create(['is_active' => true]); Product::factory()->count(2)->create(['is_active' => false]); $activeProducts = Product::active()->get(); $this->assertCount(3, $activeProducts); } }

Order Model Tests

<?php namespace Tests\Unit\Models; use Tests\TestCase; use App\Models\Order; use App\Models\User; use App\Models\OrderItem; use Illuminate\Foundation\Testing\RefreshDatabase; class OrderTest extends TestCase { use RefreshDatabase; /** @test */ public function order_belongs_to_user() { $user = User::factory()->create(); $order = Order::factory()->create([ 'user_id' => $user->id ]); $this->assertInstanceOf(User::class, $order->user); $this->assertEquals($user->id, $order->user->id); } /** @test */ public function order_has_many_items() { $order = Order::factory()->create(); OrderItem::factory()->count(3)->create([ 'order_id' => $order->id ]); $this->assertCount(3, $order->items); $this->assertInstanceOf(OrderItem::class, $order->items->first()); } /** @test */ public function order_calculates_total_correctly() { $order = Order::factory()->create(); OrderItem::factory()->create([ 'order_id' => $order->id, 'price' => 50, 'quantity' => 2 // $100 ]); OrderItem::factory()->create([ 'order_id' => $order->id, 'price' => 30, 'quantity' => 1 // $30 ]); $this->assertEquals(130, $order->calculateTotal()); } /** @test */ public function order_applies_tax_correctly() { $order = Order::factory()->create([ 'subtotal' => 100, 'tax_rate' => 0.15 // 15% ]); $this->assertEquals(15, $order->calculateTax()); $this->assertEquals(115, $order->calculateTotalWithTax()); } /** @test */ public function order_can_be_marked_as_paid() { $order = Order::factory()->create([ 'status' => 'pending' ]); $order->markAsPaid('stripe_charge_123'); $this->assertEquals('paid', $order->status); $this->assertEquals('stripe_charge_123', $order->payment_id); $this->assertNotNull($order->paid_at); } /** @test */ public function order_number_is_generated_on_creation() { $order = Order::factory()->create(); $this->assertNotNull($order->order_number); $this->assertMatchesRegularExpression( '/^ORD-[0-9]{6}$/', $order->order_number ); } }

Unit Tests: Services

Cart Service Tests

<?php namespace Tests\Unit\Services; use Tests\TestCase; use App\Models\Product; use App\Services\CartService; use Illuminate\Support\Facades\Session; class CartServiceTest extends TestCase { protected CartService $cartService; protected function setUp(): void { parent::setUp(); $this->cartService = new CartService(); } /** @test */ public function can_add_product_to_cart() { $product = Product::factory()->make([ 'id' => 1, 'price' => 100 ]); $this->cartService->add($product, 2); $cart = $this->cartService->getCart(); $this->assertCount(1, $cart); $this->assertEquals(1, $cart[0]['product_id']); $this->assertEquals(2, $cart[0]['quantity']); } /** @test */ public function adding_same_product_increases_quantity() { $product = Product::factory()->make(['id' => 1]); $this->cartService->add($product, 1); $this->cartService->add($product, 2); $cart = $this->cartService->getCart(); $this->assertCount(1, $cart); $this->assertEquals(3, $cart[0]['quantity']); } /** @test */ public function can_remove_product_from_cart() { $product = Product::factory()->make(['id' => 1]); $this->cartService->add($product, 1); $this->cartService->remove(1); $cart = $this->cartService->getCart(); $this->assertCount(0, $cart); } /** @test */ public function calculates_cart_total() { $product1 = Product::factory()->make([ 'id' => 1, 'price' => 50 ]); $product2 = Product::factory()->make([ 'id' => 2, 'price' => 30 ]); $this->cartService->add($product1, 2); // $100 $this->cartService->add($product2, 1); // $30 $total = $this->cartService->getTotal(); $this->assertEquals(130, $total); } /** @test */ public function can_clear_cart() { $product = Product::factory()->make(['id' => 1]); $this->cartService->add($product, 3); $this->cartService->clear(); $cart = $this->cartService->getCart(); $this->assertCount(0, $cart); } /** @test */ public function throws_exception_when_adding_out_of_stock_product() { $this->expectException(\App\Exceptions\OutOfStockException::class); $product = Product::factory()->make([ 'id' => 1, 'stock' => 0 ]); $this->cartService->add($product, 1); } }

Payment Service Tests

<?php namespace Tests\Unit\Services; use Tests\TestCase; use App\Models\Order; use App\Services\PaymentService; use App\Exceptions\PaymentFailedException; use Mockery; class PaymentServiceTest extends TestCase { protected PaymentService $paymentService; protected function setUp(): void { parent::setUp(); $this->paymentService = new PaymentService(); } /** @test */ public function processes_successful_payment() { $order = Order::factory()->make([ 'id' => 1, 'total' => 150.00 ]); $result = $this->paymentService->charge( $order, 'tok_visa' // Stripe test token ); $this->assertTrue($result['success']); $this->assertNotNull($result['charge_id']); } /** @test */ public function handles_declined_card() { $this->expectException(PaymentFailedException::class); $this->expectExceptionMessage('Card was declined'); $order = Order::factory()->make(['total' => 150.00]); $this->paymentService->charge( $order, 'tok_chargeDeclined' ); } /** @test */ public function validates_minimum_charge_amount() { $this->expectException(\InvalidArgumentException::class); $order = Order::factory()->make(['total' => 0.25]); // Below $0.50 min $this->paymentService->charge($order, 'tok_visa'); } /** @test */ public function can_refund_payment() { $order = Order::factory()->create([ 'total' => 100, 'payment_id' => 'ch_123456' ]); $result = $this->paymentService->refund($order); $this->assertTrue($result['success']); $this->assertEquals(100, $result['refunded_amount']); } }

Integration Tests

Order Processing Integration Test

<?php namespace Tests\Integration; use Tests\TestCase; use App\Models\User; use App\Models\Product; use App\Services\CartService; use App\Services\OrderService; use App\Services\PaymentService; use Illuminate\Foundation\Testing\RefreshDatabase; class OrderProcessingTest extends TestCase { use RefreshDatabase; /** @test */ public function complete_order_flow_works() { // Arrange $user = User::factory()->create(); $product = Product::factory()->create([ 'price' => 100, 'stock' => 10 ]); $cartService = app(CartService::class); $orderService = app(OrderService::class); // Act - Add to cart $cartService->add($product, 2); // Act - Create order $order = $orderService->createFromCart($user, $cartService); // Assert - Order created correctly $this->assertDatabaseHas('orders', [ 'id' => $order->id, 'user_id' => $user->id, 'subtotal' => 200, 'status' => 'pending' ]); // Assert - Order items created $this->assertCount(2, $order->items); // Assert - Stock decreased $this->assertEquals(8, $product->fresh()->stock); // Assert - Cart cleared $this->assertCount(0, $cartService->getCart()); } /** @test */ public function order_sends_confirmation_email() { $user = User::factory()->create(); $product = Product::factory()->create(['price' => 50]); $cartService = app(CartService::class); $orderService = app(OrderService::class); $cartService->add($product, 1); $order = $orderService->createFromCart($user, $cartService); // Process payment $order->markAsPaid('ch_test123'); // Assert email was queued $this->assertDatabaseHas('jobs', [ 'queue' => 'emails' ]); } }
Unit vs Integration Tests:
  • Unit: Test single class in isolation, mock dependencies
  • Integration: Test multiple components working together, use real dependencies

Test Data Management

Creating Test Factories

<?php namespace Database\Factories; use App\Models\Product; use Illuminate\Database\Eloquent\Factories\Factory; class ProductFactory extends Factory { protected $model = Product::class; public function definition(): array { return [ 'name' => $this->faker->words(3, true), 'description' => $this->faker->paragraph, 'price' => $this->faker->randomFloat(2, 10, 1000), 'stock' => $this->faker->numberBetween(0, 100), 'is_active' => true, ]; } public function outOfStock(): self { return $this->state([ 'stock' => 0 ]); } public function inactive(): self { return $this->state([ 'is_active' => false ]); } }
Practice Exercise:

Create comprehensive tests for a User authentication system:

  1. Unit tests: User model (password hashing, email verification)
  2. Integration tests: Registration flow (creates user, sends email)
  3. Feature tests: Login/logout endpoints
  4. Use factories for test data
  5. Test both happy paths and error cases

Summary

Part 1 covered:

  • Test Strategy: Testing pyramid and priority planning
  • Project Setup: PHPUnit configuration and dependencies
  • Unit Tests: Models (Product, Order) and Services (Cart, Payment)
  • Integration Tests: Multi-component workflows
  • Test Data: Factories and test data management

In Part 2, we'll add E2E tests, CI/CD integration, coverage reports, and documentation.