Skip to content

Commit

Permalink
Implemented testing
Browse files Browse the repository at this point in the history
  • Loading branch information
kael-shipman committed Jan 10, 2024
1 parent fb92f0e commit 57295d7
Show file tree
Hide file tree
Showing 58 changed files with 505 additions and 123 deletions.
39 changes: 12 additions & 27 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ jobs:
## Run linting/typechecks/tests
##

test:
check:
needs: [get-deploy-env]
runs-on: ubuntu-latest
steps:
Expand All @@ -140,37 +140,22 @@ jobs:
./apps/*/node_modules/.cache/eslint-cache
./libs/*/node_modules/.cache/eslint-cache
# - name: Jest Cache
# uses: actions/cache@v3
# with:
# key: jest-cache-${{ env.BRANCH }}
# restore-keys: |
# jest-cache-
# path: |
# /tmp/jest_*
- name: Jest Cache
uses: actions/cache@v3
with:
key: jest-cache-${{ env.BRANCH }}
restore-keys: |
jest-cache-
path: |
/tmp/jest_*
- name: Lint and Typecheck
- name: Lint, Typecheck and Test
run: |
pnpm \
--parallel \
--filter="${{ needs.get-deploy-env.outputs.pnpmFilter }}" \
--changed-files-ignore-pattern="${{ needs.get-deploy-env.outputs.pnpmIgnorePattern }}" \
typecheck
pnpm \
--parallel \
--filter="${{ needs.get-deploy-env.outputs.pnpmFilter }}" \
--changed-files-ignore-pattern="${{ needs.get-deploy-env.outputs.pnpmIgnorePattern }}" \
prettier
pnpm \
--parallel \
--filter="${{ needs.get-deploy-env.outputs.pnpmFilter }}" \
--changed-files-ignore-pattern="${{ needs.get-deploy-env.outputs.pnpmIgnorePattern }}" \
lint
# - name: Run Tests
# run: pnpm test --changedSince=${{ needs.get-deploy-env.outputs.affected-shas-base }}
check
##
## Pre-Seed Docker Cache
Expand All @@ -184,7 +169,7 @@ jobs:

build-and-deploy:
name: Build and Deploy ${{ matrix.service }}
needs: [get-deploy-env, test]
needs: [get-deploy-env, check]
if: "!cancelled() && !failure() && needs.get-deploy-env.outputs.deployable == 'true'"
runs-on: ubuntu-latest
strategy:
Expand Down
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,15 +238,15 @@ Some key points:
* GOTCHA: There's a dynamic section in `dockerfile.base` to mount all the libs and apps when installing deps. This
allows us to not have to risk having an out-of-date dockerfile when we add new libs and apps, but it also makes the
dockerfile unusable in raw form. Trade-offs.
* The front-end dockerfile (`./deploy/dockerfile.react-ext`) has not been battle tested and is considered a starting
point.
* The front-end container ( created by `./deploy/dockerfile.react-ext`) has not been battle tested and is considered a
starting point.
* At this time, there are no service-specific extensions, but the implemented dockerfile system allows you to provide
a file at `apps/*/deploy/dockerfile.service` if you wish to make additional changes to the final built service. If
you need to change the build target as a result, you can change the given package's `docker:build` npm script to
contain the `DOCKER_TARGET` env var before calling the script, e.g.,
`"docker:build": "DOCKER_TARGET=my-targ ../../scripts/docker-build.sh"`
* The dev docker image is simply the monorepo. And since the `/monorepo` directory is actually replaced with your live
monorepo, the image really just serves as a fixed runtime environment.
* The dev docker image is simply the monorepo. And since the `/monorepo` directory in the container is actually replaced
with your live monorepo, the image really just serves as a fixed runtime environment.
* You can build all containers using `pnpm docker:build`
* You can bring the system up using `pnpm docker:compose up -d` (dev). This brings the system up in dev mode with your
local monorepo linked in. If you want to run the actual built containers statically, try `pnpm docker:compose prod up -d`.
Expand All @@ -270,6 +270,55 @@ With that caveat, here's what to know about my eslint setup:
be ready to forge your own path here.


### Project Testing

_(As opposed to E2E testing, which is further down)_

This is another area where there's probably a lot of room for improvement over what I've done. While I'm certainly an
expert at _writing_ tests, I'm closer to a beginner than an expert in terms of all the setup and configuration for
testing, especially for a monorepo.

All that said, here are the key points in my monorepo testing setup:

* There was no way around having a `jest.config.js` file in every sub-package :sob:.
* Because it's likely that as the codebase grows I'll need more complex testing config, I created a `.jest` directory
into which I put my jest config files. The main file, `.jest/global.js`, simply defines a few global options and then
tells jest to go look in `apps` and `libs` for actual projects. The other, `.jest/common.js`, is extended in each of
the individual project configs.
* With reference to the above, note that not all options set in `global.js` actually trickle down to the projects.
* Like everything else, we're using a script under `./scripts` as our entrypoint, and we're setting our top-level `test`
command to `pnpm -r test`. You can easily filter by just adding a filter (e.g., `pnpm t --filter my-microservice`),
and you can also pass opts directly to jest, although unfortunately you have to use the old `--` argument separator
for this. For example, `pnpm t --filter my-microservice -- -t GET`.
* I have mixed opinions on front-end testing. I've found front-end tests are often brittle and of little value. I'll
typically test functions and other logic, but I don't find it very useful to write component tests. Instead, I focus
my energies on writing good E2E tests, and I prioritize my E2E tests carefully based on system importance and logical
complexity (things that are hard to reason about are higher priority, while simpler things are lower).


### E2E Testing

_TODO: Figure this out. I'm new to automated e2e testing so need to experiment before putting something out there.
Cypress is popular, but when I did some research for the last company I was at I ended up leaning toward Playwright.
Whatever you choose, just create the project in the top-level `e2e-tests` directory and go from there.


### Environment Variables

I haven't done a lot with environment variables in this monorepo. To me, `.env` files are a bit of a mess because so
many different tools have started to use them (not only your app, but also `docker compose` and other such tools).
Additionally, because people tend to use `.env` files to set vars for different environments, you often end up with a
passel of `.env` files in the top level of your project, such as `.env.prod`, `.env.staging`, `.env.qa`, etc.

My current favorite solution to this is [this](https://github.com/wymp/config-simple#usage-with-environment-specific-dot-env-files),
which uses familiar languge (`.env`), but makes it a directory instead of a file, which both neats things up and also
keeps other tooling from using the values unexpectedly.

As a final note on env vars, they're still a really great way to modify functionality in your scripts. For example, you
may want to change the way your `check` script works if it's in a CI environment. You can do this by simple implementing
the conditional functionality based on the `CI` env var.


### Boilerplate

Not strictly necessary, but if you tend to create a lot of sub-projects, it can be nice to store some boilerplate
Expand Down
2 changes: 1 addition & 1 deletion apps/my-microservice/src/deps/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Config } from "../types";
import type { Config } from '../types';

export const getConfig = (): Config => ({
port: Number(process.env.PORT) || 3000,
Expand Down
3 changes: 1 addition & 2 deletions apps/my-microservice/src/deps/prodDeps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ export const assembleDeps = async (): Promise<Deps> => {
config,
fetch: { fetch },
});
}

};
10 changes: 5 additions & 5 deletions apps/my-microservice/src/deps/testDeps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FakeFetch } from "@monorepo/testing";
import * as Express from "express";
import { Deps } from "../types";
import { getConfig } from "./config";
import { FakeFetch } from '@monorepo/testing';
import * as Express from 'express';
import { Deps } from '../types';
import { getConfig } from './config';

/**
* Our testing dependencies are all our production deps, but with a fake fetch
Expand All @@ -16,4 +16,4 @@ export const assembleDeps = async (): Promise<FakeDeps> => {
config,
fetch: new FakeFetch(),
});
}
};
14 changes: 7 additions & 7 deletions apps/my-microservice/src/http/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { MyThing, myThing } from "@monorepo/shared-types";
import type { Request, Response, NextFunction } from "express";
import type { Deps } from "../types";
import { MyThing, myThing } from '@monorepo/shared-types';
import type { Request, Response, NextFunction } from 'express';
import type { Deps } from '../types';

export const Handlers = {
"GET /": async () => (req: Request, res: Response) => {
'GET /': async () => (req: Request, res: Response) => {
const thing: MyThing = myThing;
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}}`;
res.json({ status: 'ok', timestamp: new Date().toISOString(), url, thing });
},

"GET /proxy": async (deps: Deps) => async (req: Request, res: Response, next: NextFunction) => {
'GET /proxy': async (deps: Deps) => async (req: Request, res: Response, next: NextFunction) => {
try {
if (req.query.error) {
throw new Error('Error from my-microservice');
Expand All @@ -24,5 +24,5 @@ export const Handlers = {
errors: async () => async (err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`Error in my-microservice:`, err);
res.status(500).json({ status: 'error', error: err.message });
}
}
},
};
2 changes: 1 addition & 1 deletion apps/my-microservice/src/http/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { Handlers } from './handlers';
export { Middleware } from './middleware';
export { connectRoutes } from './routes';
export { connectRoutes } from './routes';
6 changes: 3 additions & 3 deletions apps/my-microservice/src/http/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Request, Response, NextFunction } from "express";
import type { Request, Response, NextFunction } from 'express';

export const Middleware = {
cors: (req: Request, res: Response, next: NextFunction) => {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
}
}
},
};
8 changes: 4 additions & 4 deletions apps/my-microservice/src/http/routes.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { fallthroughHandler, loggingMiddleware } from '@monorepo/shared-be';
import { Middleware } from './middleware';
import { Handlers } from './handlers';
import { Deps } from "../types";
import { Deps } from '../types';

export const connectRoutes = async (deps: Deps) => {
const { app } = deps;

app.use(loggingMiddleware(`my-microservice`));
app.use(Middleware.cors);

app.get('/', await Handlers["GET /"]());
app.get('/proxy', await Handlers["GET /proxy"](deps));
app.get('/', await Handlers['GET /']());
app.get('/proxy', await Handlers['GET /proxy'](deps));

app.all('*', fallthroughHandler);

app.use(await Handlers.errors());
}
};
6 changes: 3 additions & 3 deletions apps/my-microservice/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Deps } from './types';
/**
* Defining _how_ we initialize our application separately from the dependencies we use to do it allows us to initialize
* the app in the exact same way in testing vs production, but with different (usually fake) dependencies.
*
*
* @returns A function that can be used to shut down the app when we're done with it
*/
export const initApp = async (deps: Deps) => {
Expand All @@ -20,5 +20,5 @@ export const initApp = async (deps: Deps) => {
// Return a "shutdown" function that we can use to close the server and other resources when we're done
return async () => {
server.close();
}
}
};
};
4 changes: 2 additions & 2 deletions apps/my-microservice/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { initApp } from './init';

let shutdown: () => Promise<void>;

const close = async (e: any) => {
const close = async (e: unknown) => {
if (typeof e === 'string') {
console.log(`Signal '${e}' received. Shutting down...`);
} else {
console.error(e);
}
await shutdown();
}
};

process.on('SIGTERM', close);
process.on('SIGINT', close);
Expand Down
7 changes: 3 additions & 4 deletions apps/my-microservice/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { Fetch } from "@monorepo/shared-types";
import type { Application } from "express";
import { Fetch } from '@monorepo/shared-types';
import type { Application } from 'express';

export type Config = {
port: number;
other: {
host: string;
};
}
};

export type Deps = Readonly<{
app: Application;
config: Config;
fetch: Fetch;
}>;

10 changes: 5 additions & 5 deletions apps/my-microservice/tests/e2e/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ describe(`my-microservice e2e`, () => {
// After all tests, shut the server down again
afterAll(async () => {
await shutdown();
})
});

// Before each test, clear out the fetch queue
beforeEach(() => {
deps.fetch.queue = {};
})
});

test('GET /', async () => {
const res = await fetch(`http://localhost:${port}`);
Expand All @@ -34,14 +34,14 @@ describe(`my-microservice e2e`, () => {
});

test('GET /proxy', async () => {
deps.fetch.queue['http://localhost:4000'] = [{ status: 200, jsonBody: JSON.stringify({ test: "ok" }) }];
deps.fetch.queue['http://localhost:4000'] = [{ status: 200, jsonBody: JSON.stringify({ test: 'ok' }) }];
const res = await fetch(`http://localhost:${port}/proxy`);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
status: 'ok',
timestamp: expect.any(String),
path: expect.any(String),
response: { test: "ok" },
response: { test: 'ok' },
});
});

Expand All @@ -62,4 +62,4 @@ describe(`my-microservice e2e`, () => {
error: `Endpoint GET /not-found Not found`,
});
});
});
});
2 changes: 1 addition & 1 deletion apps/my-microservice/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"types": ["node"],
"tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo"
},
"include": ["./src/**/*.ts", "../../libs/testing/src/fakeFetch.ts"]
"include": ["./src/**/*.ts"]
}
7 changes: 7 additions & 0 deletions apps/my-react-app/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const path = require("path");
const common = require('../../.jest/common');

module.exports = {
...common,
displayName: path.basename(__dirname),
}
2 changes: 2 additions & 0 deletions apps/my-react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"lint:fix": "../../scripts/lint-fix.sh",
"prettier": "../../scripts/prettier.sh",
"prettier:fix": "../../scripts/prettier-fix.sh",
"test": "../../scripts/test.sh",
"typecheck": "../../scripts/typecheck.sh"
},
"dependencies": {
Expand All @@ -24,6 +25,7 @@
"react-select": "^5.8.0"
},
"devDependencies": {
"@types/jest": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "*"
Expand Down
9 changes: 7 additions & 2 deletions apps/my-react-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MyThing, myThing } from '@monorepo/shared-types';
import { MyComponent } from '@monorepo/shared-fe';
import type { Config } from './types';
import { useEffect, useState } from 'react';
import type { Config, Deps } from './types';
import './App.css';
import { assembleDeps, DepsContext } from './deps';
import { Logos } from './containers/Logos';
Expand All @@ -10,7 +11,11 @@ import { ApiDemo } from './containers/ApiDemo';
const thing: MyThing = myThing;

function App(p: { config: Config }) {
const deps = assembleDeps(p.config);
// We might have to wait for our dependencies to load - here's how we'd do that
const [deps, setDeps] = useState<Deps | null>(null);
useEffect(() => {
assembleDeps(p.config).then(setDeps);
}, [p.config]);

if (!deps) {
return <div>Loading...</div>;
Expand Down
Loading

0 comments on commit 57295d7

Please sign in to comment.