/testable-systems-starter

A sample project to use in testing workshops with the theme of testing and "more testable" systems.

Primary LanguageTypeScript

Testable Systems Starter

A sample project to use in testing workshops with the theme of testing and building "more testable" distributed (serverless) systems.

Relevant theoretical materials include:

Other good, practical material includes:

Based on the minimalist-serverless-starter project.

Configurations

Configurations for ESLint and Prettier are reasonable starting points. The TypeScript config is very strict to get the most out of TS features. Serverless Framework is optimized (ARM architecture; short log retention; no versioning), CORS-activated, and set to safer-than-default settings.

Structure

The application starting point (the handler) is located at src/handler.ts and a first demonstrational test is at tests/unit/demo.test.ts. The rest of the tests and other "finished" materials are in the __finished__ folder and might need updates to their import paths when you place them in the root again.

Prerequisites

  • Recent Node.js (ideally 18+) installed.
  • Amazon Web Services (AWS) account with sufficient permissions so that you can deploy infrastructure. A naive but simple policy would be full rights for CloudWatch, Lambda, API Gateway, and S3.
  • Ideally some experience with Serverless Framework as that's what we will use to deploy the service and infrastructure.

Installation

Clone, fork, or download the repo as you normally would. Run npm install.

Commands

  • npm start: Run application locally
  • npm test: Test the business/application logic with Jest
  • npm run build: Package application with Serverless Framework
  • npm run deploy: Deploy application to AWS with Serverless Framework
  • npm run teardown: Remove stack from AWS

Running locally

Using npm start you can start using the local endpoint http://localhost:3000/greet to call the service.

curl http://localhost:3000/greet

Which should respond back with:

"Hi there!"

Workshop

The workshop is meant to be dynamic and interactive, but the below outlines an overall learning/experience flow for participants.

Basics

New business requirement

We need a service to greet people.

Concepts

Present and discuss

  • "Contra-variant testing": The benefits of testing the majority of code on a use-case level rather than per-function level.
  • Confidence can be causated by determinism (in code) - determinism can be achieved by controlling side effects.

Steps

  1. Look at tests/unit/demo.test.ts to familiarize yourself with the structure of a typical unit test.
  2. Look at src/handler.ts. How can we test this? How might you think about the scope of a given test - bigger, smaller and their pros/cons?
  3. Implement a unit test on the entire handler. What do you foresee as issues with this solution?
  4. Split out "business logic" from the handler. How is testing, reliability and confidence improved by doing this?
  5. Reimplement the unit test on business logic, not on the handler.

Dynamic input

New business requirement

We need support for dynamic input/output, i.e. providing and responding with your name.

Concepts

Present and discuss

  • The dangers of "dumb" POCOs/POJOs and mutability of data.
  • Leverage prior logical validation if input remains unchanged/un-mutated.

Steps

  1. Implement new functionality. How do we support both the new and old behaviors?
  2. Think about validation: At which levels can/should we validate? Once, or across all boundaries? How could we be supported by using API-level schema validation? (See api/Greeter.validator.json for an example)
  3. Implement validation functions. Demonstrate both structural/compositional ("functional") approach and object-oriented (DDD-inspired: value objects, Data Transfer Object, "always valid state") approach.
  4. Implement any additional tests.

Third-party dependency

New business requirement

For "un-named" requests, we want to send back a response so it looks something like Hi there, Luke Skywalker!.

Concepts

Present and discuss

  • Testing vs monitoring and observability.
  • Fallacies of distributed computing - What is it really that we want to test when we conduct integration testing?

Steps

Make sure to uncomment setupFilesAfterEnv in jest.config.js to get the mocking capability working.

  1. How do we test an external service?
  2. Getting test data and storing it co-located to our code and tests.
  3. API response mocking using our test data. What about schema changes?
  4. Handling errors and problem states correctly.
  5. Implement tests.

Handling persistence and other side effects

New business requirement

We need to communicate each request to our service by emitting an event.

Concepts

Present and discuss

  • How to test around boundaries of systems, especially when using managed products like message queues and databases.

Steps

  1. Implement an event emitter (see __finished__/src/infrastructure/emitter/Emitter.start.ts). What problems do we get when using this for our testing?
  2. Abstract the implementation into an interface and inject a dummy/mock/local variant for testing.
  3. Making an implementation testable ("test aware") up until the point of producing potentially adverse side effects.