Full test suite for PDF Content Search: PHPUnit (PHP) + Vitest (JavaScript/Vue).
# Run all PHP tests (inside Docker)
make test
# or:
docker compose exec php php bin/phpunit
# With HTML coverage report
docker compose exec php php bin/phpunit --coverage-html var/coverage
# Specific suite
docker compose exec php php bin/phpunit tests/Unit
docker compose exec php php bin/phpunit tests/Integration
docker compose exec php php bin/phpunit tests/Functional
# Verbose / stop on failure
docker compose exec php php bin/phpunit -v
docker compose exec php php bin/phpunit --stop-on-failuretests/
├── Unit/ # Isolated — all external deps mocked
│ ├── Search/ # Query builder + strategy logic
│ ├── Service/ # Service layer (Elasticsearch, Ollama, Analytics…)
│ └── Shared/ # SafeCallerTrait and utilities
├── Integration/ # Real PostgreSQL — requires running database service
│ └── Repository/ # SearchAnalyticsRepository, TranslationJobRepository
├── Functional/ # Full Symfony kernel via BrowserKit (HTTP)
│ └── Controller/ # SearchController, AnalyticsController, etc.
└── Fixtures/ # Elasticsearch mock responses
| Suite | CI Minimum | Project Target |
|---|---|---|
| Overall | 85% | 93% |
| Services | 90%+ | 95%+ |
| Controllers | 85%+ | 90%+ |
Rules:
- Always mock the interface, never the concrete class (
SearchEngineInterface, notElasticsearchService) - Unit tests: no real DB, no real Elasticsearch, no real Ollama — mock everything external
- Integration tests: use
KernelTestCase+ realdatabaseservice - Functional tests: use
WebTestCase; Elasticsearch is always mocked
final class OllamaEmbeddingServiceTest extends TestCase
{
public function testGenerateEmbedding(): void
{
$client = $this->createMock(HttpClientInterface::class);
// ... arrange, act, assert
}
}- Elasticsearch → mock
SearchEngineInterface/PdfIndexerInterface - Ollama → mock
EmbeddingServiceInterface/TranslationServiceInterface - PDF processing → mock
PdfProcessorInterface - Language detection → mock
LanguageDetectorInterface
Never use new ConcreteService() in tests — inject mocked interfaces.
# Run all JS/Vue tests (must run inside Docker)
docker compose exec php npm run test
# With coverage report (v8, HTML + terminal)
docker compose exec php npm run test:coverage
# Watch mode
docker compose exec php npm run test:watchWhy inside Docker?
node_modulesis installed inside the Alpine container. The host lacks the native@rolldown/binding-linux-x64-gnubinding — running on the host throwsMODULE_NOT_FOUND.
tests/Javascript/
├── components/
│ ├── analytics/ # ClickPositionChart, ExportButtons, KPICard, StrategyDistribution, TopQueriesChart, TrendsChart
│ └── search/
│ ├── states/ # Empty, Error, Initial, Loading
│ ├── Bar, Controls, Hero, Pagination, ResultCard, Results, Search, Suggestions
├── constants/ # api, languages, pagination
├── services/ # TranslationApiService
└── setup.js # Global setup (apexchart stub)
172 tests — 22 test files
| Metric | Threshold | Current |
|---|---|---|
| Statements | 80% | ~89% |
| Branches | 80% | ~87% |
| Functions | 80% | ~81% |
| Lines | 80% | ~89% |
vitest.config.js key settings:
- Environment:
happy-dom(jsdom@29 incompatible with Node 24 due to ESM top-level await) - Alias:
@→assets/ - Setup file:
tests/Javascript/setup.js— stubsapexchartglobally - Navigation:
disableMainFrameNavigation: true— prevents ECONNREFUSED from<a target="_blank">clicks
import { mount } from "@vue/test-utils"
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"
import MyComponent from "@/components/search/MyComponent.vue"
describe("MyComponent", () => {
beforeEach(() => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({}) })
vi.spyOn(console, "error").mockImplementation(() => {}) // silence expected errors
})
afterEach(() => {
vi.restoreAllMocks()
})
it("renders correctly", () => {
const wrapper = mount(MyComponent, { props: { value: "test" } })
expect(wrapper.exists()).toBe(true)
})
})Key patterns:
- Stub
apexchartglobally insetup.js(not per-test) — globally registered tag,vi.mockalone is insufficient - Mock
console.errorfor error-path tests to suppress expected catch-block output - Mock
window.openwhen testing<a target="_blank">clicks - Never name a Vue import
Error— shadows the globalErrorconstructor in Vitest SSR mode
Both suites run in CI on every push and PR:
# ci.yml jobs
php-tests: PHPUnit — ≥85% line coverage
frontend-tests: Vitest — ≥80% all coverage metricsSee .github/workflows/ci.yml for full pipeline.
.husky/pre-commit runs all checks when the php container is up:
| Step | Command |
|---|---|
| PHP-CS-Fixer | composer cs-check |
| PHPStan L8 | composer phpstan |
| Rector | composer rector-check |
| PHPUnit | composer test |
| Biome | npm run lint (inside Docker) |
| Vitest | npm run test (inside Docker) |
If the containers are not running, the hook exits 0 and warns — CI is the real quality gate.
All PHP tests mock Elasticsearch — no real connection is needed. If you see connection errors, check that you're not accidentally running unit tests without mocks.
Run npm run test inside Docker, not on the host:
docker compose exec php npm run testThe global stub is in tests/Javascript/setup.js. If you add a new chart component, ensure the setup file is loaded (it is, via vitest.config.js setupFiles).
Covered by disableMainFrameNavigation: true in vitest.config.js. If the noise reappears, verify the environmentOptions.happyDOM.settings.navigation block is present.
Last Updated: 2026-04-07