serverpod-testing-patterns
Serverpod 서비스의 테스트를 설계하거나 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()