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
0
is 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
runOnMainIsolate
option is set to true whenapplication.start
is 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
TestClient
is 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
TestClient
object, performs the GET operation and runs our assertion on the response using theexpectResponse
matcher method. It accepts the response, status code and assertion under thebody
named parameter. -
everyElement
is another matcher method that allows us to run a check against each item in the response body, assuming it's a list. -
partial
makes an assertion against specific keys, provided the list item is a Map. We use this to save us checking every single key. -
isString
andisInteger
are 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)