Apariencia
Testing
HUMAE usa Pest 3 sobre PHPUnit. El backend tiene 159+ tests / 464+ assertions con ejecución en ~4 segundos (SQLite in-memory + bcrypt 4 rounds).
Filosofía
Features > Units. Costo similar en Pest gracias a RefreshDatabase + SQLite in-memory; el valor es mucho mayor: un feature test valida controller + middleware + validation + service + resource + DB, todo de una.
Excepción: state machines + helpers puros (sin DB) → unit tests rápidos.
Estructura
tests/
├── Pest.php Config global: RefreshDatabase + TestCase para Feature/
├── TestCase.php Base class (extiende Illuminate\Foundation\Testing\TestCase)
├── Feature/
│ ├── Api/V1/
│ │ ├── Auth/
│ │ ├── Companies/
│ │ ├── Directory/
│ │ ├── Interviews/
│ │ ├── Membership/
│ │ ├── Notifications/
│ │ ├── Pipeline/
│ │ ├── Profile/
│ │ ├── Psychometric/
│ │ ├── Reports/
│ │ └── HealthTest.php
│ ├── Services/ Tests directos de services (sin hit HTTP)
│ └── Support/ ApiResponseTest, etc.
└── Unit/
└── Services/ State machines + helpers puros
├── VacancyStateMachineTest.php
├── AssignmentStageMachineTest.php
└── InterviewStateMachineTest.phpCorrer tests
bash
# Todo
composer test
# Cobertura
composer test:coverage
# Archivo específico
./vendor/bin/pest tests/Feature/Api/V1/Membership/CheckoutTest.php
# Con filter (regex)
./vendor/bin/pest --filter "idempotent"
# Grupo específico
./vendor/bin/pest --group=slow
# Watch mode
./vendor/bin/pest --watch
# Paralelo (cuidado con DB sharing)
./vendor/bin/pest --parallelConfig global
phpunit.xml
xml
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="BCRYPT_ROUNDS" value="4"/> <!-- acelera tests -->
<env name="CACHE_STORE" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>tests/Pest.php
php
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature');Solo los tests en tests/Feature/ obtienen RefreshDatabase. Los unit tests en tests/Unit/ NO tienen Laravel booteado — son más rápidos (~0.01s cada uno).
Patrón feature test
Plantilla base
php
<?php
declare(strict_types=1);
use App\Enums\UserRole;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Laravel\Sanctum\Sanctum;
beforeEach(function (): void {
$this->seed(RolesAndPermissionsSeeder::class);
});
it('requires authentication', function (): void {
$response = $this->getJson('/api/v1/me/profile');
$response->assertStatus(401);
});
it('allows a candidate to see their profile', function (): void {
$user = User::factory()->create();
$user->assignRole(UserRole::Candidate->value);
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/me/profile');
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.id', $user->id);
});
it('rejects a recruiter trying to read /me/profile (wrong role)', function (): void {
$user = User::factory()->create();
$user->assignRole(UserRole::Recruiter->value);
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/me/profile');
$response->assertStatus(403);
});Assertions útiles
php
$response->assertStatus(201);
$response->assertOk(); // 200
$response->assertUnauthorized(); // 401
$response->assertForbidden(); // 403
$response->assertNotFound(); // 404
$response->assertUnprocessable(); // 422
$response->assertJsonStructure([
'success',
'data' => [
'id', 'email', 'roles'
]
]);
$response->assertJsonPath('data.email', 'test@humae.com');
$response->assertJsonFragment(['role' => 'candidate']);
$response->assertJsonCount(3, 'data.items');
$response->assertHeader('X-RateLimit-Limit', '120');
$response->assertCookie('humae_session');DB assertions
php
$this->assertDatabaseHas('users', [
'email' => 'test@humae.com',
'status' => 'active',
]);
$this->assertDatabaseMissing('memberships', [
'user_id' => $user->id,
'status' => 'active',
]);
$this->assertDatabaseCount('vacancies', 5);Patrón test de state machine (unit)
php
<?php
declare(strict_types=1);
use App\Enums\VacancyState;
use App\Services\VacancyStateMachine;
it('allows borrador → activa', function (): void {
expect(VacancyStateMachine::canTransition(
VacancyState::Borrador,
VacancyState::Activa,
))->toBeTrue();
});
it('does not allow skipping states', function (): void {
expect(VacancyStateMachine::canTransition(
VacancyState::Borrador,
VacancyState::Cubierta,
))->toBeFalse();
});
it('does not allow exits from terminal states', function (): void {
expect(VacancyStateMachine::allowedFrom(VacancyState::Cubierta))->toBe([]);
expect(VacancyStateMachine::allowedFrom(VacancyState::Cancelada))->toBe([]);
});Patrón test de service (con DB)
php
<?php
declare(strict_types=1);
use App\Enums\MembershipStatus;
use App\Models\{User, Membership, MembershipPlan};
use App\Services\MembershipService;
beforeEach(function (): void {
$this->plan = MembershipPlan::factory()->create(['duration_days' => 180]);
$this->service = app(MembershipService::class);
});
it('expireStale() expires multiple stale memberships in one call', function (): void {
foreach (range(1, 3) as $i) {
Membership::factory()->create([
'user_id' => User::factory()->create()->id,
'membership_plan_id' => $this->plan->id,
'status' => MembershipStatus::Active,
'expires_at' => now()->subDays($i),
]);
}
expect($this->service->expireStale())->toBe(3);
expect(Membership::where('status', MembershipStatus::Expired->value)->count())
->toBe(3);
});Mocking
Stripe (evita hit real)
php
use App\Helpers\StripeClient;
use Stripe\Checkout\Session;
beforeEach(function (): void {
$fakeClient = Mockery::mock(StripeClient::class);
$fakeClient->shouldReceive('createCheckoutSession')
->andReturn(Session::constructFrom([
'id' => 'cs_test_fake',
'url' => 'https://checkout.stripe.com/c/fake',
'customer' => 'cus_test_fake',
]));
$this->app->instance(StripeClient::class, $fakeClient);
});Notifications
php
use Illuminate\Support\Facades\Notification;
use App\Notifications\MembershipActivatedNotification;
it('sends membership activation email', function (): void {
Notification::fake();
// ... disparar el flujo ...
Notification::assertSentTo(
$user,
MembershipActivatedNotification::class
);
});Queue jobs
php
use Illuminate\Support\Facades\Queue;
use App\Jobs\ExpireMembershipsJob;
it('dispatches expire job', function (): void {
Queue::fake();
// ... trigger ...
Queue::assertPushed(ExpireMembershipsJob::class);
});HTTP requests externos
php
use Illuminate\Support\Facades\Http;
it('returns fake response', function (): void {
Http::fake([
'api.stripe.com/*' => Http::response(['ok' => true], 200),
]);
// ...
});Clock (carbon)
php
use Illuminate\Support\Carbon;
it('expires in 180 days', function (): void {
Carbon::setTestNow('2026-01-01 10:00:00');
$membership = $this->service->createCheckout(...);
expect($membership->expires_at->toDateString())->toBe('2026-06-30');
});Factory helpers
Las factories se componen:
php
// Crear un candidato completo con todo
$candidate = CandidateProfile::factory()
->active()
->withMembership()
->has(CandidateExperience::factory()->count(3))
->has(CandidateLanguage::factory()->count(2))
->create();
// Crear el usuario correcto para role
$user = User::factory()->create();
$user->assignRole('recruiter');
$user->markEmailAsVerified();
// Sanctum auth
Sanctum::actingAs($user);Helpers custom
En tests/Pest.php o un archivo compañero, define helpers:
php
function makeActiveCandidate(): CandidateProfile
{
$user = User::factory()->create();
$user->assignRole(UserRole::Candidate->value);
Membership::factory()->create([
'user_id' => $user->id,
'status' => MembershipStatus::Active,
'expires_at' => now()->addDays(30),
]);
return CandidateProfile::factory()->create([
'user_id' => $user->id,
'state' => CandidateState::Activo->value,
]);
}
// Uso
it('appears in directory', function (): void {
$candidate = makeActiveCandidate();
// ...
});Colisión de nombres
Si defines makeActiveCandidate() en dos archivos distintos, PHP lanza "cannot redeclare". Soluciones:
- Usar nombres namespaced:
directoryMakeActiveCandidate() - Mover a una clase helper:
TestData::activeCandidate()
Datasets (data providers)
php
it('validates vacancy state transitions', function (VacancyState $from, VacancyState $to, bool $allowed) {
expect(VacancyStateMachine::canTransition($from, $to))->toBe($allowed);
})->with([
'borrador → activa' => [VacancyState::Borrador, VacancyState::Activa, true],
'borrador → cubierta' => [VacancyState::Borrador, VacancyState::Cubierta, false],
'activa → en_busqueda' => [VacancyState::Activa, VacancyState::EnBusqueda, true],
'cubierta → cualquiera' => [VacancyState::Cubierta, VacancyState::Activa, false],
]);Cada entry corre como un test independiente.
Performance de la suite
Objetivos:
- Total suite < 10 segundos en local (actualmente ~4s).
- Un feature test < 100ms promedio.
- Un unit test < 10ms.
Optimizaciones aplicadas
- SQLite in-memory (no toca disco).
- BCrypt rounds 4 en tests (vs 12 en prod).
RefreshDatabaseusa transaction por test (rollback rápido).MAIL_MAILER=arrayno envía correos reales.QUEUE_CONNECTION=syncejecuta jobs sincrono.
Cuando agregar $this->withoutExceptionHandling()
Por default Laravel atrapa excepciones y las convierte a 500. Si quieres ver la excepción real:
php
it('works', function () {
$this->withoutExceptionHandling();
// Ahora la excepción propaga al test
});Útil para debugging, no para prod tests.
Cobertura de código
bash
# Requiere Xdebug o pcov instalado
composer test:coverage
# HTML report en storage/coverage/Objetivo: ≥ 70% cobertura global, ≥ 90% en services críticos (MembershipService, InterviewService).
CI
GitHub Actions workflow en .github/workflows/backend.yml:
yaml
- name: Pest tests
run: composer testSe corre automáticamente en push a main y en PRs.
Siguiente
Cómo diagnosticar cuando algo falla en prod: Troubleshooting →

