DEV Community

Jermaine
Jermaine

Posted on • Edited on

Building RESTful Web APIs with Dart, Aqueduct, and PostgreSQL — Part 4: Testing

Featured image for Building RESTful Web APIs with Dart, Aqueduct, and PostgreSQL


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:

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

The application is started before running all our tests and stopped immediately afterwards. Our test harness replicates bin/main.dart with these exceptions:

  1. A port 0 is specified so that our tests can run on any available port
  2. A separate configuration file(config.src.yaml) is given containing test-specific data
  3. The runOnMainIsolate option is set to true when application.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.
  4. 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.
  5. 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
Enter fullscreen mode Exit fullscreen mode

And in our config.yaml file:

# for production environment
database:
  username: dartuser
  password: dbpass123
  host: localhost
  port: 5432
  databaseName: fave_reads
  isTemporary: false
Enter fullscreen mode Exit fullscreen mode

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);
  }
  // ...
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
  //...
}
//...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
            })));
    });
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Our first test creates a request from our TestClient object, performs the GET operation and runs our assertion on the response using the expectResponse matcher method. It accepts the response, status code and assertion under the body named parameter.
  2. everyElement is another matcher method that allows us to run a check against each item in the response body, assuming it's a list.
  3. partial makes an assertion against specific keys, provided the list item is a Map. We use this to save us checking every single key.
  4. isString and isInteger 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",
    }));
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And see the results below:
All tests passing

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

  1. Writing tests in Aqueduct
  2. Aqueduct test library documentation
  3. Dart "test" package


Originally posted on Medium

Top comments (0)