-
Notifications
You must be signed in to change notification settings - Fork 61
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
ea118fb
commit e117280
Showing
4 changed files
with
549 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`. |
Oops, something went wrong.