Skip to content

Commit

Permalink
feat: add test tools docs
Browse files Browse the repository at this point in the history
  • Loading branch information
hampuslavin committed Oct 7, 2024
1 parent ea118fb commit e117280
Show file tree
Hide file tree
Showing 4 changed files with 549 additions and 0 deletions.
344 changes: 344 additions & 0 deletions docs/06-concepts/18-testing/01-get-started.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
# Get started

Serverpod provides simple but feature rich test tools to make testing your backend a breeze.

<details>
<summary> Have an existing project? Follow these steps first!</summary>
<p>
For existing projects, a few extra things need to be done:
1. Add the `server_test_tools_path` key to `config/generator.yaml`. Without this key, the test tools file is not generated. The default location for the generated file is `integration_test/test_tools/serverpod_test_tools.dart`, but this can be set to any path (though should be outside of `lib` as per Dart's test conventions).

2. New projects now come with a test profile in `docker-compose.yaml`. This is not strictly mandatory, but is recommended to ensure that the testing state is never polluted. Add the snippet below to the `docker-compose.yaml` file in the server directory:

```yaml
# Test services
postgres_test:
image: postgres:16.3
ports:
- '9090:5432'
environment:
POSTGRES_USER: postgres_test
POSTGRES_DB: projectname_test
POSTGRES_PASSWORD: "<insert database test password>"
volumes:
- projectname_data:/var/lib/postgresql/data
profiles:
- '' # Default profile
- test
redis_test:
image: redis:6.2.6
ports:
- '9091:6379'
command: redis-server --requirepass "<insert redis test password>"
environment:
- REDIS_REPLICATION_MODE=master
profiles:
- '' # Default profile
- test
```
3. Create a `test.yaml` file and add it to the `config` directory:

```yaml
# This is the configuration file for your local test environment. By
# default, it runs a single server on port 8090. To set up your server, you will
# need to add the name of the database you are connecting to and the user name.
# The password for the database is stored in the config/passwords.yaml.
#
# When running your server locally, the server ports are the same as the public
# facing ports.
# Configuration for the main API test server.
apiServer:
port: 9080
publicHost: localhost
publicPort: 9080
publicScheme: http
# Configuration for the Insights test server.
insightsServer:
port: 9081
publicHost: localhost
publicPort: 9081
publicScheme: http
# Configuration for the web test server.
webServer:
port: 9082
publicHost: localhost
publicPort: 9082
publicScheme: http
# This is the database setup for your test server.
database:
host: localhost
port: 9090
name: projectname_test
user: postgres
# This is the setup for your Redis test instance.
redis:
enabled: false
host: localhost
port: 9091
```

4. Add this entry to `config/passwords.yaml`

```yaml
test:
database: '<insert database test password>'
redis: '<insert redis test password>'
```

That's it, the project setup should be ready to start using the test tools!
</p>
</details>

Go to the server directory and generate the test tools by running `serverpod generate`. The default location for the generated file is `integration_test/test_tools/serverpod_test_tools.dart`.

The generated file exports a `withServerpod` helper that enables you to call your endpoints directly like regular functions:

```dart
// Import the generated file, it contains everything you need.
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('Given Example endpoint', (sessionBuilder, endpoints) {
test('when calling `hello` then should return greeting', () async {
final greeting =
await endpoints.example.hello(sessionBuilder, 'Michael');
expect(greeting, 'Hello, Michael!');
});
});
}
```

A few things to note from the above example:

- The test tools should be imported from the generated test tools file and not the `serverpod_test` package.
- The `withServerpod` callback takes two parameters: `sessionBuilder` and `endpoints`.
- `sessionBuilder` is used to build a `session` object that represents the state of the world for your endpoints and is used to set up scenarios.
- `endpoints` contains all your Serverpod endpoints and lets you call them.

:::info

The location of the test tools can be changed by changing the `server_test_tools_path` key in `config/generator.yaml`. If you remove the `server_test_tools_path` key, the test tools will stop being generated.

:::

Before you can run the test you also need to start the Postgres and Redis:

```bash
docker-compose up --build --detach
```

By default this starts up both the `development` and `test` profiles. To only start one profile, simply add `--profile test` to the command.

Now the test is ready to be run!

## Using `sessionBuilder` to set up a test scenario

The `withServerpod` helper provides a `session` object that helps with setting up different scenarios for tests. It looks like the following:

```dart
/// A test specific builder to create a [Session] that for instance can be used to call database methods.
/// The builder can also be passed to endpoint calls. The builder will create a new session for each call.
abstract class TestSessionBuilder {
/// Given the properties set on the session through the `copyWith` method,
/// this returns a serverpod [Session] that has the configured state.
Session build();
/// Creates a new unique session with the provided properties.
/// This is useful for setting up different session states in the tests
/// or simulating multiple users.
TestSessionBuilder copyWith({
AuthenticationOverride? authentication,
bool? enableLogging,
});
}
```

To create a new state, simply call `copyWith` with the new properties and use the new session builder in the endpoint call.

Below follows examples of some common scenarios.

### Setting authenticated state

```dart
withServerpod('Given AuthenticatedExample endpoint',
(sessionBuilder, endpoints) {
// Corresponds to an actual user id
const int userId = 1234;
group('when authenticated', () {
var authenticatedSessionBuilder = sessionBuilder.copyWith(
authentication:
AuthenticationOverride.authenticationInfo(userId, {Scope('user')}),
);
test('then calling `hello` should return greeting', () async {
final greeting = await endpoints.authenticatedExample
.hello(authenticatedSessionBuilder, 'Michael');
expect(greeting, 'Hello, Michael!');
});
});
group('when unauthenticated', () {
var unauthenticatedSessionBuilder = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
test(
'then calling `hello` should throw `ServerpodUnauthenticatedException`',
() async {
final future = endpoints.authenticatedExample
.hello(unauthenticatedSessionBuilder, 'Michael');
await expectLater(
future, throwsA(isA<ServerpodUnauthenticatedException>()));
});
});
});
```

### Seeding the database

To seed the database before tests, simply `build` a `session` and pass to the database call exactly the same as in production code.

:::info

By default `withServerpod` does all database operations inside a transaction that is rolled back after each `test` case. See the [rollback database configuration](#rollback-database-configuration) for how to configure this behavior.

:::

```dart
withServerpod('Given Products endpoint when authenticated',
(sessionBuilder, endpoints) {
const int userId = 1234;
var authenticatedSession = sessionBuilder
.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
userId,
{Scope('user')},
),
)
.build();
setUp(() async {
await Product.db.insert(authenticatedSession, [
Product(name: 'Apple', price: 10),
Product(name: 'Banana', price: 10)
]);
});
test('then calling `all` should return all products', () async {
final products = await endpoints.products.all(authenticatedSession);
expect(products, hasLength(2));
expect(products.map((p) => p.name), contains(['Apple', 'Banana']));
});
});
```

## Environment

By default `withServerpod` uses the `test` run mode and the database settings will be read from `config/test.yaml`.

It is possible to override the default run mode by setting the `runMode` setting:

```dart
withServerpod(
'Given Products endpoint when authenticated',
(sessionBuilder, endpoints) {
/* test code */
},
runMode: ServerpodRunMode.development,
);
```

## Configuration

The following optional configuration options are available to pass as a second argument to `withServerpod`:

```dart
{
RollbackDatabase? rollbackDatabase = RollbackDatabase.afterEach,
String? runMode = ServerpodRunmode.test,
bool? enableSessionLogging = false,
bool? applyMigrations = true,
}
```

### `rollbackDatabase` {#rollback-database-configuration}

By default `withServerpod` does all database operations inside a transaction that is rolled back after each `test` case. Just like the following enum describes, the behavior of the automatic rollbacks can be configured:

```dart
/// Options for when to rollback the database during the test lifecycle.
enum RollbackDatabase {
/// After each test. This is the default.
afterEach,
/// After all tests.
afterAll,
/// Disable rolling back the database.
disabled,
}
```

There are two main reasons to change the default setting:

1. **Scenario tests**: when consecutive `test` cases depend on each other. While generally considered an anti-pattern, it can be useful when the set up for the test group is very expensive. In this case `rollbackDatabase` can be set to `RollbackDatabase.afterAll` to ensure that the database state persists between `test` cases. At the end of the `withServerpod` scope, all database changes will be rolled back.

2. **Concurrent transactions in endpoints**: when concurrent calls are made to `session.db.transaction` inside an endpoint, it is no longer possible for the Serverpod test tools to do these operations as part of a top level transaction. In this case this feature should be disabled by passing `RollbackDatabase.disabled`.

```dart
Future<void> concurrentTransactionCalls(
Session session,
) async {
await Future.wait([
session.db.transaction((tx) => /*...*/),
// Will throw `InvalidConfigurationException` if `rollbackDatabase`
// is not set to `RollbackDatabase.disabled` in `withServerpod`
session.db.transaction((tx) => /*...*/),
]);
}
```

When setting `rollbackDatabase.disabled` to be able to test `concurrentTransactionCalls`, remember that the database has to be manually cleaned up to not leak data:

```dart
withServerpod(
'Given ProductsEndpoint when calling concurrentTransactionCalls',
(sessionBuilder, endpoints) {
tearDownAll(() async {
var session = sessionBuilder.build();
// If something was saved to the database in the endpoint,
// for example a `Product`, then it has to be cleaned up!
await Product.db.deleteWhere(
session,
where: (_) => Constant.bool(true),
);
});
test('then should execute and commit all transactions', () async {
var result =
await endpoints.products.concurrentTransactionCalls(sessionBuilder);
// ...
});
},
rollbackDatabase: RollbackDatabase.disabled,
);
```

### `runMode`

The run mode that Serverpod should be running in. Defaults to `test`.

### `enableSessionLogging`

Wether session logging should be enabled. Defaults to `false`.

### `applyMigrations`

Wether pending migrations should be applied when starting Serverpod. Defaults to `true`.
Loading

0 comments on commit e117280

Please sign in to comment.