LogoSkills

serverpod-testing-patterns

Serverpod 테스트와 TDD 패턴. 엔드포인트 통합 테스트, 단위 테스트를 작성하거나 Serverpod 서비스에 TDD를 적용할 때 사용합니다.

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#

ModeDescriptionUse Case
afterEachRollback after each test (default)Most tests
afterAll Rollback after entire group Scenario tests, sequential dependencies
disabledNo automatic rollbackConcurrent 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#

OptionDefaultDescription
applyMigrationstrueApply migrations at startup
enableSessionLoggingfalseEnable session logging
rollbackDatabaseafterEachRollback timing
runModeServerpodRunMode.testRun 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 start provisions the database locally, so you can run dart test -t integration directly while implementing a feature — no docker compose up -d required. The docker compose path above is preserved as legacy for 3.x. The withServerpod API is unchanged. See serverpod-local-fullstack.

Test Exceptions#

ExceptionDescription
ServerpodUnauthenticatedExceptionCalled without authentication
ServerpodInsufficientAccessExceptionInsufficient permissions
ConnectionClosedExceptionStream 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 endpoints parameter (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()