Testing & TDD
Building a Test Suite - Part 1
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:
- Unit tests: User model (password hashing, email verification)
- Integration tests: Registration flow (creates user, sends email)
- Feature tests: Login/logout endpoints
- Use factories for test data
- 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.