Communicating big-picture architecture with your team with automated tests
How do you communicate high level architecture to your team? Do you have block diagrams on confluence? Ascii art in a README? Is there an onboarding session when a new team member joins?
How do you ensure the architecture is respected?
Automated architecture tests are a way to communicate architectural rules (and the intent behind them).
There are more sophisticated approaches to this that involve actually parsing your code and navigating the AST, but for basic rules a full-text search approach can do the work — and is simpler to grok if you're rolling your own.
The idea is to search the code for infractions, and if any are found, fail the test with the file and line number of the infraction, along with an explanation of the architectural rule in question. This may include a link to your internal wiki explaining in more detail.
For example, your team has decided to use *API instead of *Api. Consequently,
your code has fooAPI, barApi, etc.. You can go back and correct all the occurances
of *Api, but how do you keep it from creeping in again?
We'll be working with command line tools to do the text search, and you'll need the results in your test. There are several options for doing this in Node, but child_process.exec is a simple one.
const { exec } = require('child_process')
const { promisify } = require('util')
promisify(exec)('ls').then(result => {
console.log(result)
})So now what command will give us the list of files? Stack Overflow question
But we don't want to search inside node_modules, or our project's build folder, etc.
...actually everything in .gitignore should be skipped.
git grep does the trick. (See also git ls-files -x for listing files)
git grep --line-number -e ".*Api" would return something like:
src/foo/bar.js:14: const response = await fooApi.get('/')
src/quuz/baz.js:14: await fooApi.post('/create', payload)We'd like if it returned empty results — there shouldn't be any files matching .*Api.
Using child_process.exec we get the output of the command as a string. Your test could look something like
const matchedLines = result.trim().split('\n')
if(matchedLines.length) {
throw new Error(`Architecture test failed: ${matchedLines.length} files contain *Api`)
}It's not enough to detect it, though: the goal was to communicate architectural intent; to teach new team members the why. Also if you don't print the file names how can we fix the error?
You could do something like:
const matchedLines = result.trim().split('\n')
const offendingFiles = matchedLines.map(line => {
// e.g. " src/foo/bar.js:14: const response = await fooApi.get('/')"
const [file, line] = line.split(':')
return `${file} on line ${line}`
})
if(offendingFiles.length) {
throw new Error(`
Architectural rule: use *API not *Api.
The following files use *Api
${offendingFiles.map(line => ` - ${line}`)}
Read more: http://our-internal-wiki/architecture/naming
`)
}Which would fail with:
Architectural rule: use *API not *Api.
The following files use *Api
- src/foo/bar.js on line 14
- src/quuz/baz.js on line 14
Read more: http://our-internal-wiki/architecture/namingFor example, folder foo may import from folder bar, but not the other way around.
git-grep to only search in src/barrequire(.*/bar/.*)It would be really nice to have a node package that does this for you — where you define your architecture rules like:
const architectureTests = require('architecture-tests');
describe('Architecture rules', () => {
it('uses API not Api', () => {
architectureTests.entireProject.neverHas(/.*Api/, `
Use proper casing.
Read more: http://our-internal-wiki/architecture/naming
`)
})
it('does not import foo from bar', () => {
architectureTests.folder('src/bar').neverHas(/require\(.*/bar/.*\)/, `
Bar module cannot import Foo.
Consider adding to Quux, which etc. etc.
`)
}
})
Written 2019-03