Introduction
The V-Model, developed in the 1980s for aeronautical software engineering, structures projects with a downward sequence (specification, design, coding) followed by a symmetric upward phase (unit tests, integration, validation). Unlike Agile, it shines in critical contexts like finance or healthcare where traceability is essential.
Why use it in 2026? With the growing complexity of AI and cloud systems, it ensures zero-defect quality through validation at every level. Imagine building a CRUD API for sensitive user management: each phase produces testable artifacts.
This intermediate tutorial applies the V-Model to a Next.js project with Prisma (database) and Jest (testing). You'll end up with complete, deployable code and 100% test coverage. Estimated time: 2 hours. Bookmark this guide for your next code reviews.
Prerequisites
- Node.js 20+ installed
- Intermediate knowledge of TypeScript and Next.js
- Docker for the database (optional, but recommended for SQLite in dev)
- Tools: VS Code with Prisma and Jest extensions
- GitHub account for version control
Phase 1: Functional Specifications
project:
nom: "API Gestion Utilisateurs"
version: "1.0.0"
contexte: "Gestion sécurisée des profils utilisateurs pour app interne"
exigences_fonctionnelles:
- RF001: Créer un utilisateur (POST /users) avec email unique, nom, role (admin/user)
- RF002: Lister tous les utilisateurs (GET /users) paginé (limit/offset)
- RF003: Récupérer un utilisateur (GET /users/:id)
- RF004: Mettre à jour un utilisateur (PUT /users/:id)
- RF005: Supprimer un utilisateur (DELETE /users/:id)
exigences_non_fonctionnelles:
- PERF001: Temps de réponse < 200ms pour GET
- SECU001: Authentification JWT requise
- QUAL001: Couverture tests > 90%
contraintes:
- Base: PostgreSQL ou SQLite
- Framework: Next.js 15+ App RouterThis YAML file captures requirements in a traceable way, serving as a reference for the entire downward phase. Each RF is numbered to map to later tests. Avoid vague specs: always quantify (e.g., response time).
Downward Phase: High-Level Analysis and Design
The left branch of the V defines what to do. From the specs, design the overall architecture. Use diagrams to visualize:
Specifications
|
High-Level Design (Architecture: Next.js API + Prisma ORM)
|
Low-Level Design (DB Models, Endpoints)
|
Coding
|
Unit Tests ───┼─── V-Model
| |
Integration System Tests
| |
Validation |
Analogy: Like an architect drawing blueprints before pouring concrete. Here, we identify Prisma for the DB and App Router for API routes.
Phase 2: Database Schema (Prisma)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id @default(cuid())
email String @unique
name String
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
enum Role {
USER
ADMIN
}
This Prisma schema translates RF001-005 specs into a relational model. UUID for id avoids vulnerable auto-increment sequences. Pitfall: Don't forget @@map for PostgreSQL in production; test with npx prisma db push.
Phase 3: TypeScript Interfaces for Detailed Design
export enum UserRole {
USER = 'USER',
ADMIN = 'ADMIN',
}
export interface CreateUserInput {
email: string;
name: string;
role?: UserRole;
}
export interface UpdateUserInput {
name?: string;
role?: UserRole;
}
export interface User {
id: string;
email: string;
name: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
export interface PaginatedUsers {
users: User[];
total: number;
limit: number;
offset: number;
}These strict interfaces type API payloads, aligned with the specs. They prevent runtime errors via TypeScript. Pro tip: Use z.string().email() with Zod in production for runtime validation.
Coding Phase: Implementing API Routes
Now dive into concrete coding. Create a Next.js project: npx create-next-app@latest my-project --ts --app. Install Prisma (npm i prisma @prisma/client) and generate the client (npx prisma generate).
Next, implement the CRUD endpoints in /app/api/users/route.ts. Each route handles errors and pagination.
Full CRUD Implementation (Next.js API)
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = Number(searchParams.get('limit')) || 10;
const offset = Number(searchParams.get('offset')) || 0;
const [users, total] = await Promise.all([
prisma.user.findMany({ skip: offset, take: limit }),
prisma.user.count(),
]);
return NextResponse.json({ users, total, limit, offset });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const user = await prisma.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
} catch (error) {
return NextResponse.json({ error: 'Email déjà utilisé' }, { status: 400 });
}
}Complete GET/POST route for listing/creating users. Prisma optimizes queries with parallel count. Major pitfall: Always await request.json() and use try/catch for Prisma errors (e.g., unique constraint).
PUT/DELETE Routes for Complete CRUD
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
const body = await request.json();
const user = await prisma.user.update({
where: { id: params.id },
data: body,
});
return NextResponse.json(user);
}
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
await prisma.user.delete({ where: { id: params.id } });
return NextResponse.json({ message: 'Utilisateur supprimé' }, { status: 200 });
}
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
const user = await prisma.user.findUnique({ where: { id: params.id } });
if (!user) return NextResponse.json({ error: 'Non trouvé' }, { status: 404 });
return NextResponse.json(user);
}Dynamic [id] routes for read/update/delete. Use findUnique for performance. Note: In production, add auth middleware before these handlers.
Upward Phase: Unit and Integration Tests
Right branch of the V: Verify level by level.
Install Jest: npm i -D jest @types/jest ts-jest prisma-mock. Configure jest.config.js to mock Prisma.
Unit tests validate isolated code (e.g., pure functions). Integration tests check routes with a real DB.
Unit Tests with Jest (100% Coverage)
import { describe, it, expect, beforeEach } from '@jest/globals';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
describe('User CRUD', () => {
beforeEach(async () => {
await prisma.user.deleteMany({});
});
it('should create user', async () => {
const data = { email: 'test@example.com', name: 'Test' };
const user = await prisma.user.create({ data });
expect(user.email).toBe('test@example.com');
});
it('should list users paginated', async () => {
await prisma.user.createMany({ data: [{ email: '1@test.com', name: 'U1' }, { email: '2@test.com', name: 'U2' }] });
const users = await prisma.user.findMany({ take: 1 });
expect(users.length).toBe(1);
});
});Unit tests on direct Prisma operations, traceable to RF001-002. beforeEach cleans the DB. Run with npm test; aim for 100% coverage via jest --coverage.
API Integration Tests with Supertest
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
import request from 'supertest';
import { app } from '../../app/api/users/route'; // Mock app si besoin
const supertest = request('http://localhost:3000');
describe('API Users Integration', () => {
it('POST /api/users creates user', async () => {
const res = await supertest.post('/api/users').send({ email: 'int@test.com', name: 'Int' });
expect(res.status).toBe(201);
expect(res.body.email).toBe('int@test.com');
});
it('GET /api/users lists users', async () => {
const res = await supertest.get('/api/users?limit=5');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.users)).toBe(true);
});
});Install supertest to simulate HTTP calls. These tests validate route+DB integration. Run the server in parallel with jest --runInBand. Pitfall: Don't mock the DB for true integration tests.
Final Validation and Deployment
System tests (end-to-end) with Playwright or Cypress validate the entire app. Deploy to Vercel: vercel --prod. Check metrics (response time <200ms).
Traceability: Map each test to an RF (e.g., IT_RF001).
Best Practices
- Full traceability: Number requirements and tests (RF001 -> TT001)
- CI/CD tools: GitHub Actions to run tests on every push
- Phase reviews: Gate reviews before proceeding (e.g., design approved by QA)
- Living documentation: JSDoc on code + up-to-date specs
- Quality metrics: SonarQube for coverage + technical debt <5%
Common Pitfalls to Avoid
- Incomplete specs: Forgetting non-functionals (perf/security) → costly refactors
- Tests written after: Flip it! Write tests before code (TDD within V-Model)
- No mocks: Unit tests too slow without Prisma mocks
- Skipping upward phase: 70% of bugs come from integration; test early
Next Steps
Deepen your knowledge with our Learni trainings on methodologies. Resources:
- Prisma Docs
- Next.js API Routes
- Book: "Code Complete" by Steve McConnell for advanced V-Model.
Fork this example repo on GitHub and adapt it to your projects!