Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add test tools docs #167

Merged
merged 12 commits into from
Oct 10, 2024
138 changes: 138 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,138 @@
# 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:
hampuslavin marked this conversation as resolved.
Show resolved Hide resolved

```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 folder name `integration_test` is chosen to differentiate from unit tests (see the [best practises section](best-practises#unit-integration) for more information on this).

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 server state during an endpoint call and is used to set up scenarios.
- `endpoints` contains all your Serverpod endpoints and lets you call them.
SandPod marked this conversation as resolved.
Show resolved Hide resolved

:::tip

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 the test can be run the Postgres and Redis also have to be started:

```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!
hampuslavin marked this conversation as resolved.
Show resolved Hide resolved
242 changes: 242 additions & 0 deletions docs/06-concepts/18-testing/02-the-basics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
# The basics

## Using `sessionBuilder` to set up a test scenario
hampuslavin marked this conversation as resolved.
Show resolved Hide resolved

The `withServerpod` helper provides a `sessionBuilder` 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.

### Setting authenticated state

To control the authenticated state of the session, the `AuthenticationOverride` class can be used:

```dart
hampuslavin marked this conversation as resolved.
Show resolved Hide resolved
/// An override for the authentication state in a test session.
abstract class AuthenticationOverride {
/// Sets the session to be authenticated with the provided userId and scope.
static AuthenticationOverride authenticationInfo(
int userId, Set<Scope> scopes,
{String? authId});

/// Sets the session to be unauthenticated. This is the default.
static AuthenticationOverride unauthenticated();
}
```

Pass it to the `sessionBuilder.copyWith` to simulate different scenarios. Below follows an example for each case:

```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', (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 calling `all` should return all products', () async {
final products = await endpoints.products.all(sessionBuilder);
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',
(sessionBuilder, endpoints) {
/* test code */
},
runMode: ServerpodRunMode.development,
);
```

## Configuration

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

|Property|Type|Default|
|:-----|:-----|:---:|
|`rollbackDatabase`|`RollbackDatabase?`|`RollbackDatabase.afterEach`|
|`runMode`|`String?`|`ServerpodRunmode.test`|
|`enableSessionLogging`|`bool?`|`false`|
|`applyMigrations`|`bool?`|`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`.

## Test exceptions
hampuslavin marked this conversation as resolved.
Show resolved Hide resolved

The following exceptions are exported from the generated test tools file and can be thrown in various scenarios, see below.
hampuslavin marked this conversation as resolved.
Show resolved Hide resolved

|Exception|Description|
|:-----|:-----|
|`ServerpodUnauthenticatedException`|Thrown during an endpoint method call when the user was not authenticated.|
|`ServerpodInsufficientAccessException`|Thrown during an endpoint method call when the authentication key provided did not have sufficient access.|
|`ConnectionClosedException`|Thrown during an endpoint method call if a stream connection was closed with an error. For example, if the user authentication was revoked.|
|`InvalidConfigurationException`|Thrown when an invalid configuration state is found.|

## Test helpers

### `flushEventQueue`

Test helper to flush the event queue.
Useful for waiting for async events to complete before continuing the test.

```dart
Future<void> flushEventQueue();
```

For example, if depending on a generator function to execute up to its `yield`, then the
event queue can be flushed to ensure the generator has executed up to that point:

```dart
var stream = endpoints.someEndoint.generatorFunction(session);
await flushEventQueue();
```

See also [this complete example](advanced-examples#multiple-users-with-stream).
Loading
Loading