Unit Testing Guidelines
Overview
This document defines unit testing standards, best practices, and guidelines for our SaaS CRM application across both frontend (Nuxt/Vue) and backend (PHP Phalcon) codebases.
Testing Philosophy
Testing Pyramid
/\
/ \ E2E Tests (10%)
/----\
/ \ Integration Tests (30%)
/--------\
/ \ Unit Tests (60%)
/____________\
What to Test
Always Test
- Business logic
- Data transformations
- Edge cases
- Error handling
- Public APIs/methods
- Complex algorithms
- Security validations
Don't Test
- Framework code
- Third-party libraries
- Simple getters/setters
- Configuration files
- Database queries directly
- UI styling
Frontend Unit Testing (Vue/Nuxt)
Testing Setup
// package.json
{
"devDependencies": {
"@vue/test-utils": "^2.4.0",
"@vitest/ui": "^1.0.0",
"vitest": "^1.0.0",
"happy-dom": "^12.0.0",
"@testing-library/vue": "^8.0.0"
},
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: './tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'*.config.ts',
'.nuxt/',
'coverage/'
]
}
},
resolve: {
alias: {
'~': resolve(__dirname, '.'),
'@': resolve(__dirname, '.')
}
}
})
Component Testing
// components/CustomerCard.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import CustomerCard from '~/components/CustomerCard.vue'
import type { Customer } from '~/types'
describe('CustomerCard', () => {
let wrapper: VueWrapper
const mockCustomer: Customer = {
id: 1,
name: 'Test Company',
email: 'test@example.com',
status: 'active',
revenue: 50000
}
beforeEach(() => {
wrapper = mount(CustomerCard, {
props: {
customer: mockCustomer
},
global: {
plugins: [createTestingPinia()]
}
})
})
describe('Rendering', () => {
it('should render customer name', () => {
expect(wrapper.text()).toContain('Test Company')
})
it('should display correct status badge', () => {
const badge = wrapper.find('[data-testid="status-badge"]')
expect(badge.exists()).toBe(true)
expect(badge.classes()).toContain('badge-active')
})
it('should format revenue correctly', () => {
expect(wrapper.text()).toContain('$50,000')
})
})
describe('Interactions', () => {
it('should emit select event when clicked', async () => {
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('select')
expect(wrapper.emitted('select')?.[0]).toEqual([mockCustomer])
})
it('should emit edit event when edit button clicked', async () => {
const editBtn = wrapper.find('[data-testid="edit-btn"]')
await editBtn.trigger('click')
expect(wrapper.emitted()).toHaveProperty('edit')
expect(wrapper.emitted('edit')?.[0]).toEqual([mockCustomer.id])
})
it('should show confirmation before delete', async () => {
const deleteBtn = wrapper.find('[data-testid="delete-btn"]')
await deleteBtn.trigger('click')
const modal = wrapper.find('[data-testid="confirm-modal"]')
expect(modal.exists()).toBe(true)
})
})
describe('Props Validation', () => {
it('should handle missing optional props', () => {
const minimalWrapper = mount(CustomerCard, {
props: {
customer: { id: 1, name: 'Test' }
}
})
expect(minimalWrapper.exists()).toBe(true)
})
it('should apply custom classes', () => {
const customWrapper = mount(CustomerCard, {
props: {
customer: mockCustomer,
className: 'custom-class'
}
})
expect(customWrapper.classes()).toContain('custom-class')
})
})
})
Composable Testing
// composables/useCustomers.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCustomers } from '~/composables/useCustomers'
// Mock the API
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: vi.fn().mockResolvedValue({
data: [
{ id: 1, name: 'Customer 1' },
{ id: 2, name: 'Customer 2' }
]
}),
post: vi.fn().mockResolvedValue({ id: 3, name: 'New Customer' }),
put: vi.fn().mockResolvedValue({ id: 1, name: 'Updated Customer' }),
delete: vi.fn().mockResolvedValue({ success: true })
})
}))
describe('useCustomers', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should fetch customers', async () => {
const { customers, fetchCustomers } = useCustomers()
await fetchCustomers()
expect(customers.value).toHaveLength(2)
expect(customers.value[0].name).toBe('Customer 1')
})
it('should create a customer', async () => {
const { createCustomer } = useCustomers()
const newCustomer = await createCustomer({
name: 'New Customer',
email: 'new@example.com'
})
expect(newCustomer.id).toBe(3)
expect(newCustomer.name).toBe('New Customer')
})
it('should handle errors gracefully', async () => {
const api = useApi()
api.get = vi.fn().mockRejectedValue(new Error('Network error'))
const { fetchCustomers, error } = useCustomers()
await fetchCustomers()
expect(error.value).toBe('Network error')
})
})
Store Testing
// stores/auth.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '~/stores/auth'
describe('Auth Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('Login', () => {
it('should login successfully', async () => {
const store = useAuthStore()
const mockUser = { id: 1, email: 'test@example.com' }
// Mock API call
global.$fetch = vi.fn().mockResolvedValue({
user: mockUser,
token: 'jwt-token'
})
await store.login({
email: 'test@example.com',
password: 'password'
})
expect(store.isAuthenticated).toBe(true)
expect(store.user).toEqual(mockUser)
expect(store.token).toBe('jwt-token')
})
it('should handle login failure', async () => {
const store = useAuthStore()
global.$fetch = vi.fn().mockRejectedValue(
new Error('Invalid credentials')
)
await expect(store.login({
email: 'wrong@example.com',
password: 'wrong'
})).rejects.toThrow('Invalid credentials')
expect(store.isAuthenticated).toBe(false)
expect(store.error).toBe('Invalid credentials')
})
})
describe('Permissions', () => {
it('should check user permissions', () => {
const store = useAuthStore()
store.user = {
id: 1,
permissions: ['read:customers', 'write:customers']
}
expect(store.hasPermission('read:customers')).toBe(true)
expect(store.hasPermission('delete:customers')).toBe(false)
})
})
})
Backend Unit Testing (PHP Phalcon)
PHPUnit Setup
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
verbose="true">
<testsuites>
<testsuite name="Unit Tests">
<directory>tests/unit</directory>
</testsuite>
<testsuite name="Integration Tests">
<directory>tests/integration</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">app</directory>
</include>
<exclude>
<directory>app/config</directory>
<directory>app/migrations</directory>
</exclude>
<report>
<html outputDirectory="coverage"/>
<text outputFile="php://stdout"/>
</report>
</coverage>
</phpunit>
Service Testing
<?php
// tests/unit/Services/CustomerServiceTest.php
namespace Tests\Unit\Services;
use PHPUnit\Framework\TestCase;
use App\Services\CustomerService;
use App\Repositories\CustomerRepository;
use App\Models\Customers;
use Mockery;
class CustomerServiceTest extends TestCase
{
private CustomerService $service;
private $mockRepository;
private $mockCache;
protected function setUp(): void
{
parent::setUp();
$this->mockRepository = Mockery::mock(CustomerRepository::class);
$this->mockCache = Mockery::mock('Cache');
$this->service = new CustomerService();
$this->service->setRepository($this->mockRepository);
$this->service->setCache($this->mockCache);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function testCreateCustomerSuccess()
{
// Arrange
$customerData = [
'name' => 'Test Company',
'email' => 'test@example.com',
'phone' => '1234567890'
];
$expectedCustomer = new Customers();
$expectedCustomer->id = 1;
$expectedCustomer->name = 'Test Company';
$expectedCustomer->email = 'test@example.com';
$this->mockRepository
->shouldReceive('create')
->once()
->with($customerData)
->andReturn($expectedCustomer);
$this->mockCache
->shouldReceive('delete')
->with('customers:*')
->once();
// Act
$result = $this->service->createCustomer($customerData);
// Assert
$this->assertIsArray($result);
$this->assertEquals('Test Company', $result['name']);
$this->assertEquals('test@example.com', $result['email']);
}
public function testCreateCustomerValidationFailure()
{
// Arrange
$customerData = [
'name' => '', // Empty name should fail
'email' => 'invalid-email' // Invalid email format
];
// Assert
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Validation failed');
// Act
$this->service->createCustomer($customerData);
}
public function testGetCustomersWithCaching()
{
// Arrange
$params = ['page' => 1, 'limit' => 10];
$cacheKey = 'customers:' . md5(json_encode($params));
$cachedData = ['data' => [['id' => 1, 'name' => 'Cached Customer']]];
$this->mockCache
->shouldReceive('get')
->once()
->with($cacheKey)
->andReturn($cachedData);
// Repository should not be called when cache hit
$this->mockRepository
->shouldNotReceive('paginate');
// Act
$result = $this->service->getCustomers($params);
// Assert
$this->assertEquals($cachedData, $result);
}
public function testGetCustomersWithoutCache()
{
// Arrange
$params = ['page' => 1, 'limit' => 10];
$cacheKey = 'customers:' . md5(json_encode($params));
$this->mockCache
->shouldReceive('get')
->once()
->with($cacheKey)
->andReturn(null);
$expectedData = [
'data' => [['id' => 1, 'name' => 'Customer 1']],
'meta' => ['total' => 1, 'page' => 1]
];
$this->mockRepository
->shouldReceive('paginate')
->once()
->with($params)
->andReturn($expectedData);
$this->mockCache
->shouldReceive('set')
->once()
->with($cacheKey, $expectedData, 300);
// Act
$result = $this->service->getCustomers($params);
// Assert
$this->assertEquals($expectedData, $result);
}
/**
* @dataProvider customerStatusProvider
*/
public function testUpdateCustomerStatus($currentStatus, $newStatus, $shouldSucceed)
{
// Arrange
$customer = new Customers();
$customer->id = 1;
$customer->status = $currentStatus;
$this->mockRepository
->shouldReceive('find')
->once()
->with(1)
->andReturn($customer);
if ($shouldSucceed) {
$this->mockRepository
->shouldReceive('update')
->once()
->andReturn(true);
}
// Act & Assert
if (!$shouldSucceed) {
$this->expectException(\LogicException::class);
}
$result = $this->service->updateCustomerStatus(1, $newStatus);
if ($shouldSucceed) {
$this->assertTrue($result);
}
}
public function customerStatusProvider()
{
return [
['active', 'inactive', true],
['inactive', 'active', true],
['deleted', 'active', false], // Can't reactivate deleted
['active', 'deleted', true]
];
}
}
Controller Testing
<?php
// tests/unit/Controllers/CustomerControllerTest.php
namespace Tests\Unit\Controllers;
use PHPUnit\Framework\TestCase;
use App\Controllers\CustomerController;
use App\Services\CustomerService;
use Phalcon\Http\Request;
use Phalcon\Http\Response;
use Mockery;
class CustomerControllerTest extends TestCase
{
private CustomerController $controller;
private $mockService;
private $mockRequest;
private $mockResponse;
protected function setUp(): void
{
parent::setUp();
$this->mockService = Mockery::mock(CustomerService::class);
$this->mockRequest = Mockery::mock(Request::class);
$this->mockResponse = Mockery::mock(Response::class);
$this->controller = new CustomerController();
$this->controller->setService($this->mockService);
$this->controller->request = $this->mockRequest;
$this->controller->response = $this->mockResponse;
}
public function testIndexActionWithPagination()
{
// Arrange
$this->mockRequest
->shouldReceive('getQuery')
->with('page', 'int', 1)
->andReturn(2);
$this->mockRequest
->shouldReceive('getQuery')
->with('limit', 'int', 20)
->andReturn(10);
$expectedData = [
'data' => [['id' => 1, 'name' => 'Customer']],
'meta' => ['page' => 2, 'limit' => 10]
];
$this->mockService
->shouldReceive('getCustomers')
->once()
->with(Mockery::on(function ($params) {
return $params['page'] === 2 && $params['limit'] === 10;
}))
->andReturn($expectedData);
// Act
$result = $this->controller->indexAction();
// Assert
$this->assertEquals($expectedData, $result->getData());
$this->assertEquals(200, $result->getStatusCode());
}
public function testCreateActionWithValidData()
{
// Arrange
$inputData = [
'name' => 'New Customer',
'email' => 'new@example.com'
];
$this->mockRequest
->shouldReceive('getJsonRawBody')
->once()
->with(true)
->andReturn($inputData);
$createdCustomer = array_merge($inputData, ['id' => 123]);
$this->mockService
->shouldReceive('createCustomer')
->once()
->with($inputData)
->andReturn($createdCustomer);
// Act
$result = $this->controller->createAction();
// Assert
$this->assertEquals($createdCustomer, $result->getData());
$this->assertEquals(201, $result->getStatusCode());
}
public function testDeleteActionNotFound()
{
// Arrange
$customerId = 999;
$this->mockService
->shouldReceive('deleteCustomer')
->once()
->with($customerId)
->andReturn(false);
// Act
$result = $this->controller->deleteAction($customerId);
// Assert
$this->assertEquals(404, $result->getStatusCode());
$this->assertStringContainsString('not found', $result->getMessage());
}
}
Model Testing
<?php
// tests/unit/Models/CustomersTest.php
namespace Tests\Unit\Models;
use PHPUnit\Framework\TestCase;
use App\Models\Customers;
use Phalcon\Validation\Message\Group;
class CustomersTest extends TestCase
{
public function testValidationWithValidData()
{
// Arrange
$customer = new Customers();
$customer->name = 'Valid Company';
$customer->email = 'valid@example.com';
$customer->phone = '1234567890';
// Act
$result = $customer->validation();
// Assert
$this->assertFalse($result); // No validation errors
}
public function testValidationWithInvalidEmail()
{
// Arrange
$customer = new Customers();
$customer->name = 'Valid Company';
$customer->email = 'invalid-email';
// Act
$messages = $customer->validation();
// Assert
$this->assertInstanceOf(Group::class, $messages);
$this->assertStringContainsString('email', $messages[0]->getMessage());
}
public function testSoftDelete()
{
// Arrange
$customer = new Customers();
$customer->id = 1;
$customer->deleted_at = null;
// Act
$result = $customer->beforeDelete();
// Assert
$this->assertFalse($result); // Prevents actual deletion
$this->assertNotNull($customer->deleted_at);
}
public function testGetFullInfo()
{
// Arrange
$customer = new Customers();
$customer->id = 1;
$customer->name = 'Test Company';
$customer->email = 'test@example.com';
// Mock orders relationship
$mockOrders = Mockery::mock();
$mockOrders->shouldReceive('count')->andReturn(5);
$mockOrders->shouldReceive('sum')->andReturn(10000);
$customer->orders = $mockOrders;
// Act
$info = $customer->getFullInfo();
// Assert
$this->assertIsArray($info);
$this->assertEquals('Test Company', $info['name']);
$this->assertEquals(5, $info['orders_count']);
$this->assertEquals(10000, $info['total_revenue']);
}
}
Test Data Management
Factories (PHP)
<?php
// tests/factories/CustomerFactory.php
namespace Tests\Factories;
class CustomerFactory
{
public static function make(array $attributes = [])
{
$defaults = [
'name' => 'Test Company ' . uniqid(),
'email' => 'test' . uniqid() . '@example.com',
'phone' => '555-' . rand(1000, 9999),
'status' => 'active',
'created_at' => date('Y-m-d H:i:s')
];
return array_merge($defaults, $attributes);
}
public static function makeMany(int $count, array $attributes = [])
{
$customers = [];
for ($i = 0; $i < $count; $i++) {
$customers[] = self::make($attributes);
}
return $customers;
}
}
Test Fixtures (JavaScript)
// tests/fixtures/customers.ts
import type { Customer } from '~/types'
export const createCustomer = (overrides?: Partial<Customer>): Customer => ({
id: 1,
name: 'Test Company',
email: 'test@example.com',
phone: '555-1234',
status: 'active',
createdAt: new Date().toISOString(),
...overrides
})
export const createCustomers = (count: number): Customer[] => {
return Array.from({ length: count }, (_, i) =>
createCustomer({
id: i + 1,
name: `Company ${i + 1}`,
email: `company${i + 1}@example.com`
})
)
}
Coverage Requirements
Minimum Coverage Targets
| Metric | Target | Critical |
|---|---|---|
| Line Coverage | 80% | 70% |
| Branch Coverage | 75% | 65% |
| Function Coverage | 85% | 75% |
| Statement Coverage | 80% | 70% |
Coverage Reports
# Frontend coverage
npm run test:coverage
# Backend coverage
./vendor/bin/phpunit --coverage-html coverage
# Coverage enforcement in CI
if [ $(coverage_percentage) -lt 80 ]; then
echo "Coverage below 80%"
exit 1
fi
Best Practices
Test Structure
// AAA Pattern: Arrange, Act, Assert
describe('Feature', () => {
it('should do something', () => {
// Arrange: Set up test data
const input = { value: 10 }
// Act: Execute the function
const result = calculateSomething(input)
// Assert: Verify the result
expect(result).toBe(20)
})
})
Test Naming
// Good test names
it('should return user data when valid ID provided')
it('should throw error when user not found')
it('should format currency with correct symbol and decimals')
// Bad test names
it('works')
it('test user')
it('currency')
Mock Best Practices
// Mock only what you need
const mockApi = {
get: vi.fn(),
// Don't mock post, put, delete if not used
}
// Clear mocks between tests
beforeEach(() => {
vi.clearAllMocks()
})
// Verify mock calls
expect(mockApi.get).toHaveBeenCalledWith('/customers', {
params: { page: 1 }
})
Test Independence
// Bad: Tests depend on each other
let counter = 0
it('test 1', () => {
counter++
expect(counter).toBe(1)
})
it('test 2', () => {
counter++
expect(counter).toBe(2) // Fails if test 1 doesn't run
})
// Good: Independent tests
it('test 1', () => {
const counter = 1
expect(counter).toBe(1)
})
it('test 2', () => {
const counter = 2
expect(counter).toBe(2)
})
CI/CD Integration
GitHub Actions
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm run test:coverage
- uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.json
backend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: phalcon
- run: composer install
- run: ./vendor/bin/phpunit --coverage-clover coverage.xml
- uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Last Updated: January 2025 Version: 1.0.0