Skip to content

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