Serverpod Testing & TDD#
한마디로#
서버(백엔드)가 만든 기능이 실제로 제대로 작동하는지 자동으로 점검하는 방법을 모아둔 안내서입니다. 자동차를 출고하기 전에 시동, 브레이크, 안전벨트를 하나씩 확인하는 점검표와 비슷합니다. 사람이 매번 손으로 확인하는 대신, 컴퓨터가 똑같은 점검을 빠르고 정확하게 반복해 줍니다. 덕분에 새 기능을 추가하다가 기존 기능이 망가지는 사고를 미리 잡아낼 수 있습니다.
무엇을·언제#
- 무엇을 해주나요: 서버의 각 기능이 입력에 대해 올바른 결과를 내는지, 권한이 없는 사용자를 제대로 막는지, 데이터가 정확히 저장되는지를 자동으로 확인하는 코드를 작성하도록 도와줍니다.
-
언제 쓰이나요:
- 서버 기능(엔드포인트)에 대한 통합 테스트나 단위 테스트를 새로 만들 때
- "먼저 점검표부터 만들고 그다음 기능을 구현"하는 TDD 방식으로 개발할 때
- 로그인한 사용자/안 한 사용자/권한 부족 같은 인증·권한 상황을 점검할 때
- 기대 효과: 코드를 고친 뒤에도 자동 점검을 한 번 돌리면, 어디가 망가졌는지 즉시 알 수 있어 안심하고 변경할 수 있습니다.
핵심 용어#
| 용어 | 쉬운 설명 |
|---|---|
| Serverpod | 서버(백엔드)를 만드는 데 쓰는 개발 도구(프레임워크). 이 문서는 그 위에서 점검하는 방법을 다룹니다. |
| Test (테스트) | 기능이 기대대로 작동하는지 컴퓨터가 대신 확인하는 자동 점검 코드. |
| TDD (Red-Green-Refactor) | 점검표(실패하는 테스트)를 먼저 만들고(Red), 통과시킬 최소한의 기능을 구현한 뒤(Green), 코드를 깔끔하게 다듬는(Refactor) 개발 방식. |
| Endpoint (엔드포인트) | 앱이 서버에 요청을 보내는 "창구". 예: 인사말 가져오기, 상품 목록 가져오기. |
| Integration test (통합 테스트) | 실제 데이터베이스까지 연결해 기능 전체가 잘 맞물려 돌아가는지 보는 점검. |
| Authentication (인증) | 로그인한 사용자가 맞는지 확인하는 절차. 권한 없는 접근을 막는지 점검합니다. |
| DB / Database (데이터베이스) | 데이터를 저장해 두는 창고. 테스트할 때 임시 데이터를 넣었다 비웁니다. |
| Rollback (롤백) | 테스트로 넣은 임시 데이터를 점검이 끝나면 자동으로 되돌려 깨끗이 비우는 것. |
| Migration (마이그레이션) | 데이터베이스의 구조(표 모양)를 최신 상태로 맞추는 작업. |
| Stream (스트림) | 한 번이 아니라 계속 흘러들어오는 실시간 데이터 통로(예: 실시간 알림). |
| Docker | 데이터베이스 같은 부속 프로그램을 손쉽게 켜고 끄게 해주는 도구. |
Covers withServerpod-based integration tests and TDD Red-Green-Refactor patterns.
Triggers#
- Writing server tests
- withServerpod usage
- TDD workflow
- Authentication/authorization tests
Basic Test#
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('Given Greeting endpoint', (sessionBuilder, endpoints) {
test('when calling hello then returns greeting', () async {
final greeting = await endpoints.greeting.hello(sessionBuilder, 'Bob');
expect(greeting.message, 'Hello Bob');
});
});
}
Important: Import the generated test_tools/serverpod_test_tools.dart. Do not directly import
serverpod_test.
Session Builder#
Create modified sessions with copyWith, obtain Session with build():
var customSession = sessionBuilder.copyWith(...);
var session = sessionBuilder.build(); // For DB operations
Authentication Tests#
withServerpod('Given AuthEndpoint', (sessionBuilder, endpoints) {
final userId = '550e8400-e29b-41d4-a716-446655440000';
group('when authenticated', () {
var authed = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
userId, {Scope('user')}),
);
test('then hello succeeds', () async {
final greeting = await endpoints.authExample.hello(authed, 'Michael');
expect(greeting, 'Hello, Michael!');
});
});
group('when unauthenticated', () {
var unauthed = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
test('then hello throws', () async {
await expectLater(
endpoints.authExample.hello(unauthed, 'Michael'),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
});
});
DB Seeding#
withServerpod('Given Products endpoint', (sessionBuilder, endpoints) {
var session = sessionBuilder.build();
setUp(() async {
await Product.db.insert(session, [
Product(name: 'Apple', price: 10),
Product(name: 'Banana', price: 10),
]);
});
test('then all returns both products', () async {
final products = await endpoints.products.all(sessionBuilder);
expect(products, hasLength(2));
});
});
No tearDown needed — by default each test runs in a transaction that is rolled back.
Rollback Behavior#
| Mode | Description | Use Case |
|---|---|---|
afterEach | Rollback after each test (default) | Most tests |
afterAll |
Rollback after entire group | Scenario tests, sequential dependencies |
disabled | No automatic rollback | Concurrent transaction tests |
withServerpod(
'Given concurrent transactions',
(sessionBuilder, endpoints) {
tearDownAll(() async {
var session = sessionBuilder.build();
await Product.db.deleteWhere(session, where: (_) => Constant.bool(true));
});
test('then should commit all', () async {
await endpoints.products.concurrentTransactionCalls(sessionBuilder);
});
},
rollbackDatabase: RollbackDatabase.disabled,
);
Stream Tests#
withServerpod('Given shared stream', (sessionBuilder, endpoints) {
final user1 = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo('user-1', {}));
final user2 = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo('user-2', {}));
test('when posting numbers then listener receives them', () async {
var stream = endpoints.comm.listenForNumbers(user1);
await flushEventQueue(); // Wait for stream registration
await endpoints.comm.postNumber(user2, 111);
await endpoints.comm.postNumber(user2, 222);
await expectLater(stream.take(2), emitsInOrder([111, 222]));
});
});
TDD Red-Green-Refactor#
1. Red (Write Failing Test First)#
test('when creating user with duplicate email then throws', () async {
await endpoints.user.create(sessionBuilder,
User(name: 'A', email: 'dup@test.com'));
await expectLater(
endpoints.user.create(sessionBuilder,
User(name: 'B', email: 'dup@test.com')),
throwsA(isA<DuplicateEmailException>()),
);
});
2. Green (Minimal Implementation)#
Future<User> create(Session session, User user) async {
final existing = await User.db.findFirstRow(session,
where: (t) => t.email.equals(user.email));
if (existing != null) throw DuplicateEmailException(user.email);
return await User.db.insertRow(session, user);
}
3. Refactor (Improvement)#
Remove duplication, extract helpers, optimize performance — verify tests still pass.
withServerpod Options#
| Option | Default | Description |
|---|---|---|
applyMigrations | true | Apply migrations at startup |
enableSessionLogging | false | Enable session logging |
rollbackDatabase | afterEach | Rollback timing |
runMode | ServerpodRunMode.test | Run mode |
testGroupTagsOverride |
['integration'] |
Test group tags |
Test Execution#
docker compose up -d # Start DB & Redis (3.x / legacy — not needed on Serverpod 4 embedded Postgres)
dart test # All tests
dart test -t integration # Integration tests only
dart test -x integration # Unit tests only
dart test -t integration --concurrency=1 # Sequential execution
Serverpod 4: local integration tests without Docker. With Serverpod 4's embedded Postgres,
serverpod startprovisions the database locally, so you can rundart test -t integrationdirectly while implementing a feature — nodocker compose up -drequired. Thedocker composepath above is preserved as legacy for 3.x. ThewithServerpodAPI is unchanged. See serverpod-local-fullstack.
Test Exceptions#
| Exception | Description |
|---|---|
ServerpodUnauthenticatedException | Called without authentication |
ServerpodInsufficientAccessException | Insufficient permissions |
ConnectionClosedException | Stream connection closed |
InvalidConfigurationException |
Invalid configuration (nested transactions, etc.) |
DB Connection Management#
Each withServerpod creates a Serverpod instance on sessionBuilder.build(). Too many concurrent tests can exceed connection limits:
withServerpod('Given example', (sessionBuilder, endpoints) {
late Session session;
setUpAll(() { session = sessionBuilder.build(); });
// ...
});
Project Structure#
test/
├── unit/ # Pure unit tests
└── integration/ # withServerpod integration tests
Checklist#
-
Call endpoints via the
endpointsparameter (no direct instantiation) - Authentication tests: authenticated/unauthenticated/insufficient permissions cases
- DB seeding: insert data in setUp
- Concurrent transaction tests:
rollbackDatabase: disabled - Stream tests: use
flushEventQueue()