Table of Contents
Why Testing Matters (And Why You Can’t Skip It)
Building software without testing is like building a car and driving it without first checking the brakes. It might move, but you don’t want to be inside when it needs to stop.
For developers building plugins, themes, or custom applications, testing is the safety net that stops you from breaking your own code. In this comprehensive guide, we’ll break down the two most important types of testing: Unit Testing and Integration Testing.
Whether you’re a beginner or an experienced developer, you’ll learn:
- What each type of testing does
- When to use them
- How to write effective tests
- Best practices for maintainable code
Part 1: What is Unit Testing?
Think of it like this: If you are building a coffee machine, a Unit Test checks just the water heater. You disconnect it from the rest of the machine and test whether it heats water to the correct temperature. You do not care if coffee is brewed or if buttons work. You only care: “Does the heater heat water properly?”
In programming, a Unit Test checks the smallest possible piece of your code – usually a single function or method – in total isolation.
The Golden Rules of Unit Testing
- It must be fast → Should run in milliseconds
- It must be isolated → Never touches the database, file system, or internet
- It focuses on logic → Checks math, data transformation, and business rules
Real-World Example: A Discount Calculator
Imagine you’re building an eCommerce plugin. You have a simple function that calculates a discount.
The Code (The Logic)
<?php
class DiscountCalculator
{
public function applyDiscount(float $price, float $percent): float
{
if ($percent < 0 || $percent > 100) {
throw new InvalidArgumentException(
"Discount must be between 0 and 100"
);
}
return $price - ($price * ($percent / 100));
}
public function calculateBulkDiscount(float $price, int $quantity): float
{
if ($quantity >= 100) {
return $this->applyDiscount($price, 20);
} elseif ($quantity >= 50) {
return $this->applyDiscount($price, 10);
} elseif ($quantity >= 10) {
return $this->applyDiscount($price, 5);
}
return $price;
}
}
The Unit Test (The Check)
Notice we’re not saving an order to the database. We’re just checking the math.
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class DiscountCalculatorTest extends TestCase
{
private DiscountCalculator $calculator;
protected function setUp(): void
{
// This runs before each test
$this->calculator = new DiscountCalculator();
}
#[Test]
public function it_calculates_10_percent_discount_correctly()
{
// Action: Apply 10% discount to $100
$result = $this->calculator->applyDiscount(100.00, 10);
// Assertion: We expect $90.00
$this->assertEquals(90.00, $result);
}
#[Test]
public function it_calculates_50_percent_discount_correctly()
{
$result = $this->calculator->applyDiscount(200.00, 50);
$this->assertEquals(100.00, $result);
}
#[Test]
public function it_rejects_negative_discount()
{
$this->expectException(InvalidArgumentException::class);
$this->calculator->applyDiscount(50.00, -10);
}
#[Test]
public function it_rejects_discount_over_100_percent()
{
$this->expectException(InvalidArgumentException::class);
$this->calculator->applyDiscount(50.00, 150);
}
#[Test]
public function it_applies_bulk_discount_for_100_items()
{
// 100+ items get 20% off
$result = $this->calculator->calculateBulkDiscount(100.00, 100);
$this->assertEquals(80.00, $result);
}
#[Test]
public function it_applies_no_discount_for_small_orders()
{
// Less than 10 items get no discount
$result = $this->calculator->calculateBulkDiscount(100.00, 5);
$this->assertEquals(100.00, $result);
}
}
What makes this a Unit Test?
This is a Unit Test because it runs in isolation without any database calls, executes in milliseconds, focuses only on the calculation logic, and keeps each test fully independent.
Part 2: What is Integration Testing?
Think of it like this: After testing the grinder, the heater, and the pump individually, you make a full cup of coffee. An Integration Test checks whether the grinder, water heater, pump, and brewing process work together to produce a drinkable cup of coffee.
In programming, Integration Testing verifies that different parts of your system communicate correctly. This usually involves talking to a real database, sending a fake email, or checking if your plugin hooks into WordPress correctly.
The Golden Rules of Integration Testing
- It’s slower → Uses the database, so it takes longer to run
- It checks connections → Ensures data saves correctly and is retrieved correctly
- It requires cleanup → You need to wipe test data after each test
Real-World Example: Saving a User
Imagine you have a function that creates a new user in your database.
The Code (The Process)
<?php
class UserManager
{
private DatabaseConnection $db;
public function __construct(DatabaseConnection $db)
{
$this->db = $db;
}
public function createUser(string $email, string $name): User
{
// Validate email format
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email format");
}
// Check if user already exists
if ($this->db->findUserByEmail($email)) {
throw new DomainException("User with this email already exists");
}
// Save to database
return $this->db->save('users', [
'email' => $email,
'name' => $name,
'created_at' => date('Y-m-d H:i:s')
]);
}
public function getUserByEmail(string $email): ?User
{
return $this->db->findUserByEmail($email);
}
}
The Integration Test (The Connection)
Here, we actually check the database to see if the row exists.
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class UserIntegrationTest extends TestCase
{
private DatabaseConnection $db;
private UserManager $manager;
protected function setUp(): void
{
// Connect to test database (often a SQLite in-memory DB for speed)
$this->db = new DatabaseConnection('test_database');
$this->db->migrate(); // Create tables
$this->manager = new UserManager($this->db);
}
protected function tearDown(): void
{
// Clean up after each test
$this->db->truncate('users');
}
#[Test]
public function it_saves_user_to_database()
{
// Action: Create a new user
$user = $this->manager->createUser('[email protected]', 'John Doe');
// Assertion 1: User object was returned
$this->assertNotNull($user);
$this->assertEquals('[email protected]', $user->email);
$this->assertEquals('John Doe', $user->name);
// Assertion 2: User exists in the ACTUAL database
$savedUser = $this->db->findUserByEmail('[email protected]');
$this->assertNotNull($savedUser);
$this->assertEquals('John Doe', $savedUser->name);
}
#[Test]
public function it_prevents_duplicate_email_addresses()
{
// First user saves fine
$this->manager->createUser('[email protected]', 'Jane Doe');
// Second user with same email should fail
$this->expectException(DomainException::class);
$this->manager->createUser('[email protected]', 'Jane Smith');
}
#[Test]
public function it_retrieves_user_by_email()
{
// Setup: Create a user
$this->manager->createUser('[email protected]', 'Bob Smith');
// Action: Retrieve the user
$user = $this->manager->getUserByEmail('[email protected]');
// Assertion: Check the data
$this->assertNotNull($user);
$this->assertEquals('Bob Smith', $user->name);
}
#[Test]
public function it_returns_null_for_nonexistent_user()
{
$user = $this->manager->getUserByEmail('[email protected]');
$this->assertNull($user);
}
}
This is an Integration Test because it uses a real test database, verifies how the UserManager and database work together, requires setup and teardown, and runs slower than unit tests.
The Key Differences b/w Unit and Integration test
If you get confused, just remember this comparison:
| Feature | Unit Testing | Integration Testing |
|---|---|---|
| What does it test? | A single function in isolation | How modules work together |
| Speed | Very Fast (Milliseconds) | Slower (Seconds) |
| Dependencies | Faked (Mocked) – No Database | Real – Uses Database/Filesystem |
| When to use? | To check your logic and math | To check saving/loading data |
| Cost to fix bugs | Cheap (easy to find) | Moderate (harder to debug) |
| Failures indicate | Logic error in specific code | Interface/contract mismatch |
| Primary tools | PHPUnit with Mocks | PHPUnit with Test Database |
The Economic Perspective
The distinction is also economic. Bugs found at different levels have different costs:
- Unit Level Bug: Costs ~$1 to fix (code is fresh in your mind, isolated)
- Integration Level Bug: Costs ~$10 to fix (requires debugging interactions)
- Production Bug: Costs $1,000+ to fix (includes reputation damage, customer support, emergency fixes)
Best Practices for Plugin & Theme Developers
If you’re building plugins (for WordPress, etc.) or themes, follow these golden rules to keep your code clean and testable.
1. The “Mocking” Technique (Using Stunt Doubles)
In a Unit Test, you should never call a real external service (like a payment gateway or API). If the internet goes down, your test fails, even if your code is perfect.
Instead, use a Mock. A Mock is like a stunt double. It pretends to be the API and returns a fake “Success” message so you can test how your code handles it.
Example: Testing Payment Processing
<?php
// The interface (contract) that payment gateways must follow
interface PaymentGateway
{
public function charge(float $amount): bool;
}
class OrderProcessor
{
private PaymentGateway $gateway;
public function __construct(PaymentGateway $gateway)
{
$this->gateway = $gateway;
}
public function processOrder(Order $order): string
{
$success = $this->gateway->charge($order->total);
if ($success) {
$order->markAsPaid();
return "Payment successful";
}
return "Payment failed";
}
}
// The Unit Test with a Mock
class OrderProcessorTest extends TestCase
{
#[Test]
public function it_processes_successful_payment()
{
// Create a mock payment gateway
$mockGateway = $this->createMock(PaymentGateway::class);
$mockGateway->method('charge')->willReturn(true);
$processor = new OrderProcessor($mockGateway);
$order = new Order(100.00);
$result = $processor->processOrder($order);
$this->assertEquals("Payment successful", $result);
$this->assertTrue($order->isPaid());
}
#[Test]
public function it_handles_failed_payment()
{
// Mock returns false (payment declined)
$mockGateway = $this->createMock(PaymentGateway::class);
$mockGateway->method('charge')->willReturn(false);
$processor = new OrderProcessor($mockGateway);
$order = new Order(100.00);
$result = $processor->processOrder($order);
$this->assertEquals("Payment failed", $result);
$this->assertFalse($order->isPaid());
}
}
2. Dependency Injection (Don’t Use new Inside Methods)
This is the #1 mistake beginners make.
❌ Bad Code (Hard to Test)
class Order
{
public function process()
{
// Problem! You can't replace this in a test
$mailer = new EmailService();
$mailer->send('Order Confirmed');
}
}
Why is this bad?
- When you test this, it will send REAL emails
- You can’t check if the email was sent correctly
- If the email server is down, your test fails
✅ Good Code (Easy to Test)
class Order
{
private EmailService $mailer;
// Pass the mailer in. Now you can pass a "FakeMailer" during testing!
public function __construct(EmailService $mailer)
{
$this->mailer = $mailer;
}
public function process()
{
$this->mailer->send('Order Confirmed');
}
}
// In your test:
class OrderTest extends TestCase
{
#[Test]
public function it_sends_confirmation_email()
{
$mockMailer = $this->createMock(EmailService::class);
$mockMailer->expects($this->once())
->method('send')
->with('Order Confirmed');
$order = new Order($mockMailer);
$order->process();
}
}
3. Use the Testing Pyramid
Don’t try to write an Integration Test for everything. It will make your test suite too slow.
/\
/ \ 10% End-to-End Tests
/ \ (Browser automation, full user flows)
/------\
/ \ 20% Integration Tests
/ \ (Database, API connections)
/------------\
/ \ 70% Unit Tests
/________________\ (Fast logic checks)
The Strategy:
- 70% Unit Tests: Fast checks for all your logic
- 20% Integration Tests: Checking if the database and APIs connect
- 10% End-to-End (E2E) Tests: Simulating a real user clicking buttons in a browser
4. Don’t Test the Framework
If you’re using WordPress, don’t write a test to check if User::save() works. The creators of WordPress have already tested that. Only test your custom code that uses those functions.
❌ Don’t Do This
#[Test]
public function wordpress_can_generate_post_slug()
{
// Create a real WordPress post
$post_id = wp_insert_post([
'post_title' => 'Hello World',
'post_status' => 'publish',
]);
$this->assertIsInt( $post_id );
// Fetch the post from the database
$post = get_post( $post_id );
// WordPress generates the slug internally
$this->assertEquals( 'hello-world', $post->post_name ); // Testing WordPress's code, not yours!
}
✅ Do This Instead
#[Test]
public function it_generates_slug_from_title()
{
$slugGenerator = new SlugGenerator();
// Test only YOUR logic in isolation
$slug = $slugGenerator->fromTitle('Hello World Post');
$this->assertEquals('hello-world-post', $slug);
}
5. Complete Working Example (WordPress Plugin)
Let’s put it all together with a real-world scenario using a WordPress plugin.
The Plugin: Custom Shipping Calculator
<?php
/**
* Calculates shipping costs based on weight and distance
*/
class ShippingCalculator
{
private const BASE_RATE = 5.00;
private const RATE_PER_KG = 2.50;
private const RATE_PER_KM = 0.10;
public function calculateCost(float $weightKg, float $distanceKm): float
{
if ($weightKg <= 0 || $distanceKm <= 0) {
throw new InvalidArgumentException(
"Weight and distance must be positive"
);
}
$cost = self::BASE_RATE;
$cost += $weightKg * self::RATE_PER_KG;
$cost += $distanceKm * self::RATE_PER_KM;
return round($cost, 2);
}
public function isFreeShippingEligible(float $orderTotal): bool
{
return $orderTotal >= 100.00;
}
}
/**
* Saves shipping quotes to database
*/
class ShippingQuoteManager
{
private $db;
private ShippingCalculator $calculator;
public function __construct($wpdb, ShippingCalculator $calculator)
{
$this->db = $wpdb;
$this->calculator = $calculator;
}
public function saveQuote(int $orderId, float $weight, float $distance): int
{
$cost = $this->calculator->calculateCost($weight, $distance);
$this->db->insert(
$this->db->prefix . 'shipping_quotes',
[
'order_id' => $orderId,
'weight_kg' => $weight,
'distance_km' => $distance,
'cost' => $cost,
'created_at' => current_time('mysql')
],
['%d', '%f', '%f', '%f', '%s']
);
return $this->db->insert_id;
}
}
Unit Tests (Fast, Isolated)
<?php
class ShippingCalculatorTest extends TestCase
{
private ShippingCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new ShippingCalculator();
}
#[Test]
public function it_calculates_shipping_for_light_package()
{
// 2kg package, 10km distance
// Base: $5 + Weight: $5 (2*2.50) + Distance: $1 (10*0.10) = $11
$cost = $this->calculator->calculateCost(2, 10);
$this->assertEquals(11.00, $cost);
}
#[Test]
public function it_calculates_shipping_for_heavy_package()
{
// 50kg package, 100km distance
$cost = $this->calculator->calculateCost(50, 100);
$this->assertEquals(140.00, $cost);
}
#[Test]
public function it_rejects_zero_weight()
{
$this->expectException(InvalidArgumentException::class);
$this->calculator->calculateCost(0, 10);
}
#[Test]
public function it_offers_free_shipping_for_orders_over_100()
{
$this->assertTrue($this->calculator->isFreeShippingEligible(100.00));
$this->assertTrue($this->calculator->isFreeShippingEligible(150.00));
}
#[Test]
public function it_denies_free_shipping_for_small_orders()
{
$this->assertFalse($this->calculator->isFreeShippingEligible(99.99));
$this->assertFalse($this->calculator->isFreeShippingEligible(50.00));
}
}
Integration Tests (Database Interaction)
<?php
class ShippingQuoteIntegrationTest extends WP_UnitTestCase
{
private ShippingQuoteManager $manager;
protected function setUp(): void
{
parent::setUp();
global $wpdb;
$calculator = new ShippingCalculator();
$this->manager = new ShippingQuoteManager($wpdb, $calculator);
}
#[Test]
public function it_saves_quote_to_database()
{
global $wpdb;
// Save a shipping quote
$quoteId = $this->manager->saveQuote(
orderId: 123,
weight: 5.0,
distance: 25.0
);
// Verify it was saved
$this->assertIsInt($quoteId);
$this->assertGreaterThan(0, $quoteId);
// Check the actual database
$quote = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}shipping_quotes WHERE id = %d",
$quoteId
)
);
$this->assertNotNull($quote);
$this->assertEquals(123, $quote->order_id);
$this->assertEquals(5.0, $quote->weight_kg);
$this->assertEquals(25.0, $quote->distance_km);
$this->assertEquals(19.50, $quote->cost); // Calculated correctly
}
}
Use unit tests when you are validating business logic such as calculations or data transformations, verifying conditional logic, working with functions that do not rely on external systems, and when you want fast feedback during development.
Choose integration tests when your code interacts with a database, calls external APIs, relies on WordPress hooks or filters, or when you need to confirm that multiple components work correctly together.
Golden rules to follow: start by covering your core logic with unit tests, use dependency injection to keep your code testable, mock external dependencies in unit tests, and follow the testing pyramid with most tests at the unit level. Avoid testing the framework itself, keep every test independent, and always give tests clear, descriptive names that explain exactly what they verify.
Testing isn’t just extra work, it’s insurance against future bugs. Every test you write today is a bug you won’t have to fix in production tomorrow.
Start small:
- Identify your most critical calculation or logic
- Write one unit test for it
- Add edge cases
- Gradually expand coverage
Remember: Unit tests ensure you built the thing right (logic), while integration tests ensure you built the right thing that works within the ecosystem.

Leave a Reply