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/naming
For example, folder foo
may import from folder bar
, but not the other way around.
git-grep
to only search in src/bar
require(.*/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