Testing¶
This document provides comprehensive guidance for testing the Stratpoint Timesheet Application, including unit tests, integration tests, end-to-end tests, and testing best practices.
Testing Strategy¶
Testing Pyramid¶
graph TB
A[End-to-End Tests] --> B[Integration Tests]
B --> C[Unit Tests]
D[Manual Testing] --> A
E[API Testing] --> B
F[Component Testing] --> C
G[Performance Testing] --> H[Load Testing]
G --> I[Stress Testing]
J[Security Testing] --> K[Vulnerability Scanning]
J --> L[Penetration Testing]
Test Types and Coverage¶
| Test Type | Coverage Target | Tools | Frequency |
|---|---|---|---|
| Unit Tests | 90%+ | PHPUnit, Jest | Every commit |
| Integration Tests | 80%+ | PHPUnit, Cypress | Every PR |
| End-to-End Tests | Critical paths | Cypress, Selenium | Daily |
| API Tests | All endpoints | Postman, PHPUnit | Every PR |
| Performance Tests | Key scenarios | JMeter, Artillery | Weekly |
| Security Tests | All features | OWASP ZAP, SonarQube | Weekly |
PHP/Laravel Testing¶
Unit Testing Setup¶
<?php
namespace Tests\Unit\Services;
use App\Models\User;
use App\Models\Project;
use App\Models\Timesheet;
use App\Services\TimesheetService;
use App\Repositories\TimesheetRepository;
use App\Events\TimesheetCreated;
use App\Exceptions\TimesheetException;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
use Mockery;
class TimesheetServiceTest extends TestCase
{
use RefreshDatabase;
private TimesheetService $timesheetService;
private TimesheetRepository $timesheetRepository;
protected function setUp(): void
{
parent::setUp();
$this->timesheetRepository = Mockery::mock(TimesheetRepository::class);
$this->timesheetService = new TimesheetService($this->timesheetRepository);
Event::fake();
}
/** @test */
public function it_creates_timesheet_entry_successfully()
{
// Arrange
$user = User::factory()->create();
$project = Project::factory()->create();
$timesheetData = [
'project_id' => $project->id,
'log_date' => '2024-01-15',
'spent_hours' => 8.0,
'description' => 'Development work',
'is_billable' => true,
];
$expectedTimesheet = new Timesheet([
'user_id' => $user->id,
...$timesheetData
]);
$this->timesheetRepository
->shouldReceive('create')
->once()
->with(array_merge(['user_id' => $user->id], $timesheetData))
->andReturn($expectedTimesheet);
// Act
$result = $this->timesheetService->createTimesheet($user, $timesheetData);
// Assert
$this->assertInstanceOf(Timesheet::class, $result);
$this->assertEquals($project->id, $result->project_id);
$this->assertEquals(8.0, $result->spent_hours);
Event::assertDispatched(TimesheetCreated::class, function ($event) use ($expectedTimesheet) {
return $event->timesheet->id === $expectedTimesheet->id;
});
}
/** @test */
public function it_validates_hours_do_not_exceed_daily_limit()
{
// Arrange
$user = User::factory()->create();
$project = Project::factory()->create();
$timesheetData = [
'project_id' => $project->id,
'log_date' => '2024-01-15',
'spent_hours' => 25.0, // Exceeds 24 hours
'description' => 'Overtime work',
'is_billable' => true,
];
// Act & Assert
$this->expectException(TimesheetException::class);
$this->expectExceptionMessage('Hours cannot exceed 24 per day');
$this->timesheetService->createTimesheet($user, $timesheetData);
}
/** @test */
public function it_calculates_weekly_hours_correctly()
{
// Arrange
$user = User::factory()->create();
$startDate = Carbon::parse('2024-01-15');
$endDate = Carbon::parse('2024-01-21');
$timesheets = collect([
new Timesheet(['spent_hours' => 8.0]),
new Timesheet(['spent_hours' => 7.5]),
new Timesheet(['spent_hours' => 8.0]),
new Timesheet(['spent_hours' => 8.0]),
new Timesheet(['spent_hours' => 6.0]),
]);
$this->timesheetRepository
->shouldReceive('getUserTimesheetsByDateRange')
->once()
->with($user->id, $startDate, $endDate)
->andReturn($timesheets);
// Act
$totalHours = $this->timesheetService->getWeeklyHours($user, $startDate);
// Assert
$this->assertEquals(37.5, $totalHours);
}
/** @test */
public function it_handles_timesheet_approval_workflow()
{
// Arrange
$user = User::factory()->create();
$approver = User::factory()->create(['role' => 'manager']);
$timesheet = Timesheet::factory()->create([
'user_id' => $user->id,
'approval_status' => 'pending'
]);
$this->timesheetRepository
->shouldReceive('find')
->once()
->with($timesheet->id)
->andReturn($timesheet);
$this->timesheetRepository
->shouldReceive('update')
->once()
->with($timesheet->id, [
'approval_status' => 'approved',
'approved_by' => $approver->id,
'approved_at' => Mockery::type(Carbon::class)
])
->andReturn($timesheet);
// Act
$result = $this->timesheetService->approveTimesheet($timesheet->id, $approver);
// Assert
$this->assertEquals('approved', $result->approval_status);
$this->assertEquals($approver->id, $result->approved_by);
}
}
Feature Testing¶
<?php
namespace Tests\Feature\Api;
use App\Models\User;
use App\Models\Project;
use App\Models\Timesheet;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class TimesheetApiTest extends TestCase
{
use RefreshDatabase, WithFaker;
/** @test */
public function authenticated_user_can_create_timesheet_entry()
{
// Arrange
$user = User::factory()->create();
$project = Project::factory()->create();
Sanctum::actingAs($user);
$timesheetData = [
'project_id' => $project->id,
'log_date' => '2024-01-15',
'spent_hours' => 8.0,
'description' => 'Development work',
'is_billable' => true,
];
// Act
$response = $this->postJson('/api/timesheets', $timesheetData);
// Assert
$response->assertStatus(201)
->assertJsonStructure([
'data' => [
'id',
'project_id',
'log_date',
'spent_hours',
'description',
'is_billable',
'approval_status'
],
'message'
]);
$this->assertDatabaseHas('timelogs', [
'user_id' => $user->id,
'project_id' => $project->id,
'spent_hours' => 8.0,
]);
}
/** @test */
public function user_cannot_create_timesheet_for_future_date()
{
// Arrange
$user = User::factory()->create();
$project = Project::factory()->create();
Sanctum::actingAs($user);
$futureDate = now()->addDays(5)->format('Y-m-d');
$timesheetData = [
'project_id' => $project->id,
'log_date' => $futureDate,
'spent_hours' => 8.0,
'description' => 'Future work',
'is_billable' => true,
];
// Act
$response = $this->postJson('/api/timesheets', $timesheetData);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['log_date']);
}
/** @test */
public function user_can_retrieve_their_timesheets()
{
// Arrange
$user = User::factory()->create();
$project = Project::factory()->create();
$timesheets = Timesheet::factory()->count(3)->create([
'user_id' => $user->id,
'project_id' => $project->id,
]);
Sanctum::actingAs($user);
// Act
$response = $this->getJson('/api/timesheets');
// Assert
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => [
'id',
'project_id',
'log_date',
'spent_hours',
'description',
'is_billable',
'approval_status'
]
],
'meta'
]);
}
/** @test */
public function user_cannot_access_other_users_timesheets()
{
// Arrange
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$project = Project::factory()->create();
$timesheet = Timesheet::factory()->create([
'user_id' => $user2->id,
'project_id' => $project->id,
]);
Sanctum::actingAs($user1);
// Act
$response = $this->getJson("/api/timesheets/{$timesheet->id}");
// Assert
$response->assertStatus(403);
}
}
JavaScript/AngularJS Testing¶
Unit Testing with Jasmine¶
// tests/unit/controllers/TimesheetControllerSpec.js
describe('TimesheetController', function() {
var controller, scope, TimesheetService, NotificationService, $q;
beforeEach(module('timesheet'));
beforeEach(inject(function($controller, $rootScope, _$q_) {
scope = $rootScope.$new();
$q = _$q_;
// Mock services
TimesheetService = jasmine.createSpyObj('TimesheetService', [
'getTimesheets',
'createTimesheet',
'updateTimesheet',
'deleteTimesheet'
]);
NotificationService = jasmine.createSpyObj('NotificationService', [
'success',
'error'
]);
controller = $controller('TimesheetController', {
$scope: scope,
TimesheetService: TimesheetService,
NotificationService: NotificationService
});
}));
describe('initialization', function() {
it('should initialize with default values', function() {
expect(controller.timesheets).toEqual([]);
expect(controller.selectedDate).toBeDefined();
expect(controller.isLoading).toBe(false);
expect(controller.errors).toEqual({});
});
it('should load timesheets on initialization', function() {
var mockTimesheets = [
{ id: 1, project_id: 1, spent_hours: 8 },
{ id: 2, project_id: 2, spent_hours: 6 }
];
TimesheetService.getTimesheets.and.returnValue(
$q.resolve({ data: mockTimesheets })
);
controller.loadTimesheets();
scope.$apply();
expect(TimesheetService.getTimesheets).toHaveBeenCalled();
expect(controller.timesheets).toEqual(mockTimesheets);
expect(controller.isLoading).toBe(false);
});
});
describe('saveTimesheet', function() {
it('should save new timesheet successfully', function() {
var newTimesheet = {
project_id: 1,
spent_hours: 8,
description: 'Development work'
};
var savedTimesheet = { id: 1, ...newTimesheet };
TimesheetService.createTimesheet.and.returnValue(
$q.resolve({ data: savedTimesheet })
);
TimesheetService.getTimesheets.and.returnValue(
$q.resolve({ data: [savedTimesheet] })
);
controller.saveTimesheet(newTimesheet);
scope.$apply();
expect(TimesheetService.createTimesheet).toHaveBeenCalledWith(newTimesheet);
expect(NotificationService.success).toHaveBeenCalledWith('Timesheet saved successfully');
expect(controller.timesheets).toEqual([savedTimesheet]);
});
it('should handle validation errors', function() {
var invalidTimesheet = {
project_id: null,
spent_hours: 0
};
var result = controller.saveTimesheet(invalidTimesheet);
expect(result).toBeDefined();
expect(controller.errors.project).toBe('Project is required');
expect(controller.errors.hours).toBe('Hours must be greater than 0');
});
it('should handle server errors', function() {
var timesheet = {
project_id: 1,
spent_hours: 8
};
var errorResponse = {
status: 422,
data: {
errors: {
spent_hours: ['Hours exceed daily limit']
}
}
};
TimesheetService.createTimesheet.and.returnValue(
$q.reject(errorResponse)
);
controller.saveTimesheet(timesheet);
scope.$apply();
expect(controller.errors).toEqual(errorResponse.data.errors);
});
});
describe('validation', function() {
it('should validate required fields', function() {
var timesheet = {};
var isValid = controller.validateTimesheet(timesheet);
expect(isValid).toBe(false);
expect(controller.errors.project).toBe('Project is required');
expect(controller.errors.hours).toBe('Hours must be greater than 0');
});
it('should validate hours range', function() {
var timesheet = {
project_id: 1,
spent_hours: 25
};
var isValid = controller.validateTimesheet(timesheet);
expect(isValid).toBe(false);
expect(controller.errors.hours).toBe('Hours cannot exceed 24 per day');
});
it('should pass validation for valid timesheet', function() {
var timesheet = {
project_id: 1,
spent_hours: 8
};
var isValid = controller.validateTimesheet(timesheet);
expect(isValid).toBe(true);
expect(Object.keys(controller.errors)).toEqual([]);
});
});
});
Service Testing¶
// tests/unit/services/TimesheetServiceSpec.js
describe('TimesheetService', function() {
var TimesheetService, $httpBackend, ApiConfig;
beforeEach(module('timesheet'));
beforeEach(inject(function(_TimesheetService_, _$httpBackend_, _ApiConfig_) {
TimesheetService = _TimesheetService_;
$httpBackend = _$httpBackend_;
ApiConfig = _ApiConfig_;
}));
afterEach(function() {
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
});
describe('getTimesheets', function() {
it('should fetch timesheets for date range', function() {
var startDate = new Date('2024-01-15');
var endDate = new Date('2024-01-21');
var mockResponse = {
data: [
{ id: 1, spent_hours: 8 },
{ id: 2, spent_hours: 6 }
]
};
$httpBackend
.expectGET(ApiConfig.baseUrl + '/api/timesheets?start_date=2024-01-15&end_date=2024-01-21')
.respond(200, mockResponse);
var result;
TimesheetService.getTimesheets(startDate, endDate)
.then(function(response) {
result = response;
});
$httpBackend.flush();
expect(result).toEqual(mockResponse);
});
it('should handle API errors', function() {
var startDate = new Date('2024-01-15');
$httpBackend
.expectGET(ApiConfig.baseUrl + '/api/timesheets?start_date=2024-01-15&end_date=2024-01-15')
.respond(500, { error: 'Server error' });
var error;
TimesheetService.getTimesheets(startDate)
.catch(function(response) {
error = response;
});
$httpBackend.flush();
expect(error.status).toBe(500);
});
});
describe('createTimesheet', function() {
it('should create new timesheet', function() {
var timesheet = {
project_id: 1,
spent_hours: 8,
description: 'Development work'
};
var mockResponse = {
data: { id: 1, ...timesheet }
};
$httpBackend
.expectPOST(ApiConfig.baseUrl + '/api/timesheets', timesheet)
.respond(201, mockResponse);
var result;
TimesheetService.createTimesheet(timesheet)
.then(function(response) {
result = response;
});
$httpBackend.flush();
expect(result).toEqual(mockResponse);
});
});
});
End-to-End Testing¶
Cypress E2E Tests¶
// cypress/e2e/timesheet.cy.js
describe('Timesheet Management', () => {
beforeEach(() => {
// Login before each test
cy.login('test@stratpoint.com', 'password');
cy.visit('/timesheet');
});
it('should allow user to create timesheet entry', () => {
// Select project
cy.get('[data-cy=project-select]').click();
cy.get('[data-cy=project-option]').first().click();
// Enter hours
cy.get('[data-cy=hours-input]').type('8');
// Enter description
cy.get('[data-cy=description-input]').type('Development work on user authentication');
// Save timesheet
cy.get('[data-cy=save-button]').click();
// Verify success message
cy.get('[data-cy=notification]').should('contain', 'Timesheet saved successfully');
// Verify entry appears in list
cy.get('[data-cy=timesheet-list]').should('contain', '8.00');
cy.get('[data-cy=timesheet-list]').should('contain', 'Development work');
});
it('should validate required fields', () => {
// Try to save without selecting project
cy.get('[data-cy=save-button]').click();
// Verify validation errors
cy.get('[data-cy=project-error]').should('contain', 'Project is required');
cy.get('[data-cy=hours-error]').should('contain', 'Hours must be greater than 0');
});
it('should allow editing existing timesheet', () => {
// Create a timesheet first
cy.createTimesheet({
project_id: 1,
spent_hours: 6,
description: 'Initial work'
});
// Edit the timesheet
cy.get('[data-cy=edit-button]').first().click();
cy.get('[data-cy=hours-input]').clear().type('8');
cy.get('[data-cy=description-input]').clear().type('Updated work description');
cy.get('[data-cy=save-button]').click();
// Verify changes
cy.get('[data-cy=timesheet-list]').should('contain', '8.00');
cy.get('[data-cy=timesheet-list]').should('contain', 'Updated work description');
});
it('should calculate weekly totals correctly', () => {
// Create multiple timesheet entries
const entries = [
{ hours: 8, description: 'Monday work' },
{ hours: 7.5, description: 'Tuesday work' },
{ hours: 8, description: 'Wednesday work' },
{ hours: 6, description: 'Thursday work' },
{ hours: 4, description: 'Friday work' }
];
entries.forEach((entry, index) => {
cy.get('[data-cy=project-select]').click();
cy.get('[data-cy=project-option]').first().click();
cy.get('[data-cy=hours-input]').type(entry.hours.toString());
cy.get('[data-cy=description-input]').type(entry.description);
cy.get('[data-cy=save-button]').click();
if (index < entries.length - 1) {
cy.get('[data-cy=next-day-button]').click();
}
});
// Verify weekly total
cy.get('[data-cy=weekly-total]').should('contain', '33.5');
});
});
Performance Testing¶
Load Testing with Artillery¶
# tests/performance/load-test.yml
config:
target: 'https://timesheet.stratpoint.com'
phases:
- duration: 60
arrivalRate: 5
name: "Warm up"
- duration: 120
arrivalRate: 10
name: "Ramp up load"
- duration: 300
arrivalRate: 20
name: "Sustained load"
payload:
path: "users.csv"
fields:
- "email"
- "password"
scenarios:
- name: "User login and timesheet operations"
weight: 70
flow:
- post:
url: "/api/auth/login"
json:
email: "{{ email }}"
password: "{{ password }}"
capture:
- json: "$.token"
as: "authToken"
- get:
url: "/api/timesheets"
headers:
Authorization: "Bearer {{ authToken }}"
- post:
url: "/api/timesheets"
headers:
Authorization: "Bearer {{ authToken }}"
json:
project_id: 1
log_date: "2024-01-15"
spent_hours: 8
description: "Load test entry"
is_billable: true
- name: "Dashboard access"
weight: 30
flow:
- post:
url: "/api/auth/login"
json:
email: "{{ email }}"
password: "{{ password }}"
capture:
- json: "$.token"
as: "authToken"
- get:
url: "/api/dashboard"
headers:
Authorization: "Bearer {{ authToken }}"
Test Automation¶
CI/CD Pipeline Testing¶
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
unit-tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: timesheet_test
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: |
php artisan test --coverage --min=80
php artisan test --testsuite=Feature
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run E2E tests
run: npm run test:e2e
security-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run security scan
run: |
composer audit
npm audit
- name: OWASP ZAP Scan
uses: zaproxy/action-full-scan@v0.4.0
with:
target: 'https://staging.timesheet.stratpoint.com'
This comprehensive testing guide ensures robust quality assurance for the Stratpoint Timesheet Application across all layers of the application stack.