PLEASE NOTE: As of Dart 2 the API for Aqueduct has changed, leading to breaking changes. This article was based on Aqueduct 2.5.0 for Dart v1.
I have updated this as a new video series: http://bit.ly/aqueduct-tutorial
In Part 3 we integrated our API with a PostgreSQL database, utilising Aqueduct's ORM as a means of managing our data transactions. Having learnt about managed objects and managed contexts, we landed a solution that provided data persistence without the need of writing complex SQL queries.
This article is part of a series, covering these topics:
- Part 1: Setting up and running the example
- Part 2: Implementing routing with CRUD operations
- Part 3: Connecting Web APIs to PostgreSQL database
- Part 4: Writing tests (we're here)
- *Bonus content* DB Migration and Model Relationships 😄
In this part we'll be writing our tests while refactoring our logic to accommodate these tests. We will be using Aqueduct's inbuilt testing library built atop the Dart team's test package, and we are saved the hustle of setting this up ourselves.
The test harness
Using the scaffolding tool in Part 1 created a test/ folder at the project root, with the file structure below:
test/
|--harness
|--app.dart
example_test.dart
The test harness harness/app.dart is responsible for starting and stopping our application. We see this in effect when looking at this snippet in test/example_test.dart:
TestApplication app = new TestApplication();
// Runs before all tests
setUpAll(() async {
await app.start();
});
// Runs after all tests
tearDownAll(() async {
await app.stop();
});
The application is started before running all our tests and stopped immediately afterwards. Our test harness replicates bin/main.dart with these exceptions:
- A port
0is specified so that our tests can run on any available port - A separate configuration file(config.src.yaml) is given containing test-specific data
- The
runOnMainIsolateoption is set to true whenapplication.startis called, running our test on a main thread. This disables multi-threading so that we can access the application's state and services to perform our assertions. - A
TestClientis instantiated to provide a HTTP client for performing requests to our APIs. Using this will give us test responses for making our assertions on. - A method for stopping the application is provided to be invoked after all tests are run
In order to run our tests, let's refactor our solution in Part 3, starting with our database configuration. This is to allow flexibility to support testing and production environments.
1. Configure the database
Looking again at FaveReadsSink in lib/fave_reads_sink.dart, the same Postgres details will be used in our test and production environments (Yikes!)
The use of configuration files help mitigate this by separating test and production information. In our project we will be working with config.src.yaml and config.yaml files at the root level.
Let's start by amending these files with our db connection information:
# config.src.yaml - for test environment
database:
username: dartuser
password: dbpass123
host: localhost
port: 5432
databaseName: fave_reads_test
isTemporary: true
And in our config.yaml file:
# for production environment
database:
username: dartuser
password: dbpass123
host: localhost
port: 5432
databaseName: fave_reads
isTemporary: false
This allows us to make the following modifications to FaveReadsSink:
// fave_reads_sink.dart
// ...
// ...
class FaveReadsSink extends RequestSink {
FaveReadsConfiguration config;
FaveReadsSink(ApplicationConfiguration appConfig) : super(appConfig) {
logger.onRecord.listen(
(rec) => print("$rec ${rec.error ?? ""} ${rec.stackTrace ?? ""}"));
var configFilePath = appConfig.configurationFilePath;
config = new FaveReadsConfiguration(configFilePath);
var managedDataModel = new ManagedDataModel.fromCurrentMirrorSystem();
var persistentStore = new PostgreSQLPersistentStore.fromConnectionInfo(
config.database.username,
config.database.password,
config.database.host,
config.database.port,
config.database.databaseName);
ManagedContext.defaultContext =
new ManagedContext(managedDataModel, persistentStore);
}
// ...
// ...
}
Our configuration file path is specified by the configurationFilePath property on the Application constructor in bin/main.dart and test/harness/app.dart. Our FaveReadsSink class is instantiated inside an application object, via which it receives the configuration path in the appConfig argument of our request sink constructor.
We then extract our configuration information by instantiating FaveReadsConfiguration, a subclass of the ConfigurationItem helper class. This parses the configuration file as a Map.
After the request sink, let's define our configuration item:
class FaveReadsConfiguration extends ConfigurationItem {
FaveReadsConfiguration(String fileName) : super.fromFile(fileName);
DatabaseConnectionConfiguration database;
}
The database property maps to the same key in our configuration file. This is what exposes our database information to be accessed like such: config.database.username
2. Extract SchemaBuilder into utility file
Let's move the createDatabaseSchema method into a separate file to also be used by our test harness:
// This goes in lib/utils/utils.dart
import 'dart:async';
import 'package:aqueduct/aqueduct.dart';
Future createDatabaseSchema(ManagedContext context, bool isTemporary) async {
try {
var builder = new SchemaBuilder.toSchema(
context.persistentStore,
new Schema.fromDataModel(context.dataModel),
isTemporary: isTemporary);
for (var cmd in builder.commands) {
await context.persistentStore.execute(cmd);
}
} catch (e) {
// Database may already exist
}
}
We now have the second parameter isTemporary to be set by our configuration files. This option determines whether our data is persisted or not. We set this to true for our tests.
Let's now import this utility back into lib/fave_reads_sink.dart:
import 'fave_reads.dart';
import './controller/books_controller.dart';
import './utils/utils.dart'; // 👈👈👈
class FaveReadsSink extends RequestSink {
//...
//...
@override
Future willOpen() async {
await createDatabaseSchema(
ManagedContext.defaultContext, config.database.isTemporary);
}
//...
}
//...
3. Set up the testing database
Open the psql tool and run the query below:
CREATE DATABASE fave_reads_test;
CREATE USER dartuser;
ALTER USER dartuser WITH password 'dbpass123';
GRANT ALL ON database fave_reads_test TO dartuser;
You can skip lines 2 and 3 if you already did this in Part 3.
4. Write your tests
Rename example_test.dart to books_controller_test.dart and replace its contents with the below:
On line 39 we call the discardPersistentData method in order to disconnect and reconnect the database, using the same setup data for each test we run. Since our test data is temporary, it only lasts for the duration of the connection.
We still need to create this method in our test harness:
// test/harness/app.dart
class TestApplication {
// ...
Future discardPersistentData() async {
await ManagedContext.defaultContext
.persistentStore.close();
await createDatabaseSchema(
ManagedContext.defaultContext, true);
}
// ...
}
Our tests are contained within a main() top-level function as required by Dart in order to run our tests. The setUp function creates a list of Book types and using the Query<T> object we populate the database for each test. Calling the query object reopens the database during the test.
Let's create a test by replacing the //...tests to go here comment with the snippet below:
group("books controller", () {
test("GET /books returns list of books", () async {
// Arrange
var request = app.client.request("/books");
// Act
var response = await request.get();
// Assert
expectResponse(response, 200,
body: everyElement(partial(
{
"title": isString,
"author": isString,
"year": isInteger
})));
});
});
The group function is used for categorising related tests, similar to having the describe block if you've worked with the Jasmine BDD framework and test is similar to the it block.
Some further things to take note of:
- Our first test creates a request from our
TestClientobject, performs the GET operation and runs our assertion on the response using theexpectResponsematcher method. It accepts the response, status code and assertion under thebodynamed parameter. -
everyElementis another matcher method that allows us to run a check against each item in the response body, assuming it's a list. -
partialmakes an assertion against specific keys, provided the list item is a Map. We use this to save us checking every single key. -
isStringandisIntegerare other inbuilt getters for ensuring the type is what we expect
Here's the next one for a POST request:
test("POST /books creates a new book", () async {
var request = app.client.request("/books")
..json = {
"title": "JavaScript: The Good Parts",
"author": "Douglas Crockford",
"year": 2008
};
expectResponse(await request.post(), 200,
body: partial({
"id": 4,
"title": "JavaScript: The Good Parts",
}));
});
The post request object accepts a body through the json property. This assumes the payload is a JSON string.
Below is our full automated test:
We can run our tests by doing:
dart test/books_controller_test.dart
Conclusion
Our APIs are now covered by tests. I hope you learnt something useful and may find it worth considering Aqueduct for your next project.
Check out the reading materials below to understand Aqueduct testing in further detail. As always, feedback is welcome. Let me know what you liked, disliked and what you'd want to see next. I'd really be grateful for that.
And this concludes Part 4 of the series. The source code is available on github. Stay tuned for bonus content.
Further reading
Article No Longer Available
Originally posted on Medium


Top comments (0)