Skip to main content

Testing Guide

This guide covers testing strategies, tools, and best practices for BudgetControl development.

Testing Philosophy

BudgetControl follows a comprehensive testing strategy:

  • Unit Tests: Test individual components in isolation
  • Integration Tests: Test component interactions
  • Feature Tests: Test complete user workflows
  • API Tests: Test HTTP endpoints and responses
  • End-to-End Tests: Test the complete application flow

Test Structure

Directory Organization

tests/
├── Unit/
│ ├── Services/
│ ├── Models/
│ └── Helpers/
├── Feature/
│ ├── Api/
│ ├── Controllers/
│ └── Middleware/
├── Integration/
│ ├── Database/
│ └── External/
└── Support/
├── Factories/
├── Fixtures/
└── Helpers/

PHP/Laravel Testing

Setting Up Tests

Test Database Configuration

// phpunit.xml
<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="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
</php>

Base Test Class

<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

abstract class TestCase extends BaseTestCase
{
use CreatesApplication, RefreshDatabase;

protected function setUp(): void
{
parent::setUp();

// Seed base data
$this->artisan('db:seed', ['--class' => 'TestDataSeeder']);

// Set up authentication
$this->actingAs($this->createTestUser());
}

protected function createTestUser(): User
{
return User::factory()->create([
'email' => 'test@budgetcontrol.dev',
]);
}
}

Unit Testing

Service Layer Testing

<?php

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Services\BudgetCalculationService;
use App\Models\Entry;
use App\Models\Budget;
use Mockery;

class BudgetCalculationServiceTest extends TestCase
{
private BudgetCalculationService $service;

protected function setUp(): void
{
parent::setUp();
$this->service = new BudgetCalculationService();
}

/** @test */
public function it_calculates_budget_percentage_correctly(): void
{
// Arrange
$budget = Budget::factory()->create(['amount' => 1000]);
$entries = collect([
Entry::factory()->make(['amount' => -300]),
Entry::factory()->make(['amount' => -200]),
]);

// Act
$percentage = $this->service->calculateSpentPercentage($budget, $entries);

// Assert
$this->assertEquals(50.0, $percentage);
}

/** @test */
public function it_handles_zero_budget_amount(): void
{
// Arrange
$budget = Budget::factory()->create(['amount' => 0]);
$entries = collect([Entry::factory()->make(['amount' => -100])]);

// Act & Assert
$this->expectException(InvalidArgumentException::class);
$this->service->calculateSpentPercentage($budget, $entries);
}
}

Model Testing

<?php

namespace Tests\Unit\Models;

use Tests\TestCase;
use App\Models\Entry;
use App\Models\Account;

class EntryTest extends TestCase
{
/** @test */
public function it_has_required_attributes(): void
{
$entry = Entry::factory()->create();

$this->assertNotNull($entry->uuid);
$this->assertNotNull($entry->amount);
$this->assertNotNull($entry->date_time);
}

/** @test */
public function it_belongs_to_an_account(): void
{
$account = Account::factory()->create();
$entry = Entry::factory()->create(['account_id' => $account->id]);

$this->assertInstanceOf(Account::class, $entry->account);
$this->assertEquals($account->id, $entry->account->id);
}

/** @test */
public function it_formats_amount_correctly(): void
{
$entry = Entry::factory()->create(['amount' => -123.456]);

$this->assertEquals('-123.46', $entry->formatted_amount);
}

/** @test */
public function it_scopes_by_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();

Entry::factory()->count(3)->create(['workspace_id' => $workspace1->id]);
Entry::factory()->count(2)->create(['workspace_id' => $workspace2->id]);

$entries = Entry::forWorkspace($workspace1->id)->get();

$this->assertCount(3, $entries);
}
}

Feature Testing

API Endpoint Testing

<?php

namespace Tests\Feature\Api;

use Tests\TestCase;
use App\Models\Entry;
use App\Models\Account;

class EntryControllerTest extends TestCase
{
/** @test */
public function it_can_list_entries_with_pagination(): void
{
// Arrange
Entry::factory()->count(50)->create();

// Act
$response = $this->getJson('/api/entries?page=1&per_page=20');

// Assert
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => [
'uuid',
'amount',
'note',
'date_time',
'account',
]
],
'meta' => [
'current_page',
'per_page',
'total',
]
])
->assertJsonCount(20, 'data');
}

/** @test */
public function it_can_create_an_entry(): void
{
// Arrange
$account = Account::factory()->create();
$category = Category::factory()->create();

$data = [
'amount' => -50.99,
'note' => 'Coffee shop purchase',
'account_id' => $account->id,
'category_id' => $category->id,
'date_time' => '2024-01-15T10:30:00Z',
];

// Act
$response = $this->postJson('/api/entries', $data);

// Assert
$response->assertStatus(201)
->assertJsonStructure([
'data' => [
'uuid',
'amount',
'note',
]
]);

$this->assertDatabaseHas('entries', [
'amount' => -50.99,
'note' => 'Coffee shop purchase',
]);
}

/** @test */
public function it_validates_required_fields_on_creation(): void
{
// Act
$response = $this->postJson('/api/entries', []);

// Assert
$response->assertStatus(422)
->assertJsonValidationErrors([
'amount',
'account_id',
'category_id',
]);
}

/** @test */
public function it_can_filter_entries_by_account(): void
{
// Arrange
$account1 = Account::factory()->create();
$account2 = Account::factory()->create();

Entry::factory()->count(3)->create(['account_id' => $account1->id]);
Entry::factory()->count(2)->create(['account_id' => $account2->id]);

// Act
$response = $this->getJson("/api/entries?filters[account_id]={$account1->id}");

// Assert
$response->assertStatus(200)
->assertJsonCount(3, 'data');
}
}

Authentication Testing

<?php

namespace Tests\Feature\Auth;

use Tests\TestCase;

class AuthenticationTest extends TestCase
{
/** @test */
public function it_requires_authentication_for_protected_routes(): void
{
$response = $this->getJson('/api/entries');

$response->assertStatus(401);
}

/** @test */
public function it_accepts_valid_bearer_token(): void
{
$user = User::factory()->create();
$token = $user->createToken('test-token')->plainTextToken;

$response = $this->withHeaders([
'Authorization' => "Bearer {$token}",
])->getJson('/api/entries');

$response->assertStatus(200);
}

/** @test */
public function it_requires_workspace_header(): void
{
$user = User::factory()->create();

$response = $this->actingAs($user)
->getJson('/api/entries');

$response->assertStatus(400)
->assertJson([
'error' => 'Workspace header required'
]);
}
}

Database Testing

Migration Testing

<?php

namespace Tests\Integration\Database;

use Tests\TestCase;
use Illuminate\Support\Facades\Schema;

class MigrationTest extends TestCase
{
/** @test */
public function entries_table_has_expected_columns(): void
{
$this->assertTrue(Schema::hasTable('entries'));

$columns = [
'id', 'uuid', 'amount', 'note', 'date_time',
'account_id', 'category_id', 'workspace_id',
'created_at', 'updated_at', 'deleted_at'
];

foreach ($columns as $column) {
$this->assertTrue(
Schema::hasColumn('entries', $column),
"Column {$column} does not exist in entries table"
);
}
}

/** @test */
public function entries_table_has_proper_indexes(): void
{
$indexes = $this->getTableIndexes('entries');

$this->assertContains('entries_workspace_id_date_time_index', $indexes);
$this->assertContains('entries_account_id_confirmed_index', $indexes);
}

private function getTableIndexes(string $table): array
{
return DB::select("
SELECT indexname
FROM pg_indexes
WHERE tablename = ?
", [$table]);
}
}

Frontend Testing (Vue.js)

Component Testing with Vue Test Utils

// tests/components/EntryForm.spec.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import EntryForm from '@/components/EntryForm.vue'

describe('EntryForm', () => {
it('renders form fields correctly', () => {
const wrapper = mount(EntryForm)

expect(wrapper.find('input[name="amount"]').exists()).toBe(true)
expect(wrapper.find('input[name="note"]').exists()).toBe(true)
expect(wrapper.find('select[name="account"]').exists()).toBe(true)
})

it('emits submit event with form data', async () => {
const wrapper = mount(EntryForm)

await wrapper.find('input[name="amount"]').setValue('-50.99')
await wrapper.find('input[name="note"]').setValue('Coffee')
await wrapper.find('form').trigger('submit.prevent')

expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')[0]).toEqual([{
amount: -50.99,
note: 'Coffee',
accountId: ''
}])
})

it('validates required fields', async () => {
const wrapper = mount(EntryForm)

await wrapper.find('form').trigger('submit.prevent')

expect(wrapper.find('.error-message').text()).toContain('Amount is required')
})

it('disables submit button when loading', async () => {
const wrapper = mount(EntryForm, {
props: { isLoading: true }
})

expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined()
})
})

Store/Composable Testing

// tests/stores/entries.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useEntriesStore } from '@/stores/entries'
import * as entryService from '@/services/entryService'

vi.mock('@/services/entryService')

describe('Entries Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})

it('loads entries successfully', async () => {
const mockEntries = [
{ uuid: '1', amount: -50, note: 'Coffee' },
{ uuid: '2', amount: -100, note: 'Lunch' }
]

vi.mocked(entryService.getEntries).mockResolvedValue({
data: mockEntries,
meta: { total: 2 }
})

const store = useEntriesStore()
await store.loadEntries()

expect(store.entries).toEqual(mockEntries)
expect(store.isLoading).toBe(false)
})

it('handles errors when loading entries', async () => {
vi.mocked(entryService.getEntries).mockRejectedValue(
new Error('Network error')
)

const store = useEntriesStore()
await store.loadEntries()

expect(store.entries).toEqual([])
expect(store.error).toBe('Failed to load entries')
})

it('adds new entry to store', async () => {
const newEntry = { uuid: '3', amount: -25, note: 'Snack' }

vi.mocked(entryService.createEntry).mockResolvedValue(newEntry)

const store = useEntriesStore()
await store.createEntry({ amount: -25, note: 'Snack' })

expect(store.entries).toContain(newEntry)
})
})

API Testing with Postman/Newman

Automated API Testing

// postman-tests/entry-endpoints.js
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});

pm.test("Response has correct structure", function () {
const jsonData = pm.response.json();
pm.expect(jsonData).to.have.property('data');
pm.expect(jsonData.data).to.be.an('array');
});

pm.test("Entry has required fields", function () {
const jsonData = pm.response.json();
const entry = jsonData.data[0];

pm.expect(entry).to.have.property('uuid');
pm.expect(entry).to.have.property('amount');
pm.expect(entry).to.have.property('date_time');
});

pm.test("Amount is properly formatted", function () {
const jsonData = pm.response.json();
const entry = jsonData.data[0];

pm.expect(entry.amount).to.be.a('number');
pm.expect(entry.amount.toString()).to.match(/^-?\d+(\.\d{1,2})?$/);
});

Running API Tests

# Install Newman globally
npm install -g newman

# Run Postman collection
newman run postman-collections/BudgetControl-API.postman_collection.json \
--environment postman-environments/dev.postman_environment.json \
--reporters html,cli \
--reporter-html-export test-results.html

Performance Testing

Load Testing with Artillery

# artillery-config.yml
config:
target: 'https://dev.app.budgetcontrol.cloud'
phases:
- duration: 60
arrivalRate: 10
headers:
Authorization: 'Bearer {{ $processEnvironment.AUTH_TOKEN }}'
X-Bc-Token: '{{ $processEnvironment.BC_TOKEN }}'
X-Bc-Ws: '{{ $processEnvironment.BC_WS }}'

scenarios:
- name: 'Get entries'
weight: 70
flow:
- get:
url: '/api/entries?page={{ $randomInt(1, 10) }}'

- name: 'Create entry'
weight: 30
flow:
- post:
url: '/api/entries'
json:
amount: '{{ $randomNumber(-500, -1) }}'
note: 'Load test entry'
account_id: 1
category_id: 1

Database Performance Testing

<?php

namespace Tests\Performance;

use Tests\TestCase;
use App\Models\Entry;

class DatabasePerformanceTest extends TestCase
{
/** @test */
public function entries_query_performs_well_with_large_dataset(): void
{
// Arrange: Create large dataset
Entry::factory()->count(10000)->create();

// Act: Measure query time
$start = microtime(true);

$entries = Entry::with(['account', 'category'])
->where('date_time', '>=', now()->subMonth())
->paginate(50);

$duration = microtime(true) - $start;

// Assert: Query should complete within reasonable time
$this->assertLessThan(1.0, $duration, 'Query took too long');
$this->assertCount(50, $entries->items());
}
}

Test Data Management

Factories

<?php

namespace Database\Factories;

use App\Models\Entry;
use Illuminate\Database\Eloquent\Factories\Factory;

class EntryFactory extends Factory
{
protected $model = Entry::class;

public function definition(): array
{
return [
'uuid' => $this->faker->uuid(),
'amount' => $this->faker->randomFloat(2, -500, -1),
'note' => $this->faker->sentence(),
'date_time' => $this->faker->dateTimeBetween('-1 year', 'now'),
'confirmed' => $this->faker->boolean(90),
'account_id' => Account::factory(),
'category_id' => Category::factory(),
'workspace_id' => Workspace::factory(),
];
}

public function expense(): static
{
return $this->state(fn () => [
'amount' => $this->faker->randomFloat(2, -500, -1),
'type' => 'expense',
]);
}

public function income(): static
{
return $this->state(fn () => [
'amount' => $this->faker->randomFloat(2, 1, 5000),
'type' => 'income',
]);
}
}

Test Seeders

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\User;
use App\Models\Workspace;

class TestDataSeeder extends Seeder
{
public function run(): void
{
$user = User::factory()->create([
'email' => 'test@budgetcontrol.dev',
]);

$workspace = Workspace::factory()->create([
'user_id' => $user->id,
'name' => 'Test Workspace',
]);

// Create test accounts, categories, etc.
Account::factory()->count(3)->create([
'workspace_id' => $workspace->id,
]);

Category::factory()->count(10)->create();
}
}

CI/CD Testing Pipeline

GitHub Actions Configuration

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
tests:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: budget_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v3

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
extensions: dom, curl, libxml, mbstring, zip, pdo, pgsql
coverage: xdebug

- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Setup environment
run: |
cp .env.testing .env
php artisan key:generate

- name: Run migrations
run: php artisan migrate

- name: Run tests
run: php artisan test --coverage --min=80

Best Practices

Test Organization

  • Group related tests in the same file
  • Use descriptive test names
  • Follow AAA pattern (Arrange, Act, Assert)
  • Keep tests independent and isolated

Performance

  • Use database transactions for faster tests
  • Mock external services
  • Use in-memory databases for unit tests
  • Parallel test execution where possible

Maintenance

  • Regular test review and cleanup
  • Update tests with code changes
  • Monitor test coverage metrics
  • Remove obsolete tests

Documentation

  • Comment complex test scenarios
  • Document test data setup
  • Explain non-obvious assertions
  • Keep README updated with test instructions

This comprehensive testing approach ensures BudgetControl maintains high quality and reliability across all components.