Low-effort things you can do today to improve code maintainability a year later
When it comes to code maintainability in the long run, one has to strike a balance and avoid:
- Over-engineering — spending a lot of time upfront designing things that turn out to be unnecessarily complex (or just unnecessary).
- Under-engineering — writing spaghetti code to ship fast, and paying for the technical debt later, suffering through bug-ridden untestable code and painful restructurings and/or rewrites.
In this note, I will list a few practices that takes minimal up-front effort to do, but end up saving a lot of time down the line. Each section will also feature a few examples from real-world code. I will assume that you already have a basic level of code hygiene, e.g. using linters and formatting your code in a consistent style.
Own your interface
Instead of depending on external libraries’ API, consider owning the API yourself.
You don’t have to design the API by yourself. The easiest low-effort approach is to steal the API from external modules by re-exporting them in your own modules. Make your application code depend only on your API instead of external APIs.
When I follow this guideline, it lets me insulate myself from:
- Breaking changes when upgrading the external library.
- Rewriting a lot of code when I want to switch to a new library.
- Adding features not provided by the third-party library.
Before:
// app.js
import { Button } from 'semantic-ui-react'
After:
// ui.js
export { Button } from 'semantic-ui-react'
// app.js
import { Button } from '@/ui'
If, for example, one year later I decide to switch to MUI, I can do so while keeping most of my code intact.
At this point, the project will probably have 100 files that imports the Button component. That little re-export I did 1 year ago would have saved the effort of having to change 100 files.
Some people call this an anti-corruption layer. TypeScript’s structural typing makes building such layers very lightweight.
Switch to MUI:
// ui.js
import Button from '@mui/material/Button'
export function Button(props) {
const variant = props.primary
? 'contained'
: props.secondary
? 'outlined'
: 'text'
return (
<Button onClick={props.onClick} variant={variant}>
{props.children}
</Button>
)
}
Real-world examples
By owning the
Storageinterface which is derived from a strict minimal subset of Google Cloud Storage’s JavaScript API…It becomes trivial to create a mock storage for testing purposes.
A
new gcs.Storage()can be used where ourStorageis expected without having to write any abstraction layer, thanks to TypeScript’s structural typing.
Types:
type Storage = {
bucket: (bucketName: string) => Bucket
}
type Bucket = {
file: (key: string) => File
}
type File = {
download: () => Promise<[Buffer]>
}
App:
import * as gcs from '@google-cloud/storage'
const storage: Storage = new gcs.Storage()
Sometimes doing acceptance tests against UIs requires using a lot of helper libraries to set up the component hierarchy. This can easily lead to import hell. By owning the interface for the helper libraries…
- I can use all helpers in my tests without having to importing them from each npm package; the package where each helper comes from now becomes just an implementation detail.
- I can also create more idiomatic helpers (such as
renderInTestWrapper) leading to cleaner tests and less boilerplate, without having to add more import statements.
At Taskworld, using the same technique, we were able to…
- migrate from WebdriverIO library (v4)
- …to Selenium’s official JavaScript API in 20191, and
- …to Playwright in 2021,
- with very little changes to the test scripts.
Before:
import { setupWorker, rest } from 'msw'
import { Provider } from 'react-redux'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { QueryClient, QueryClientProvider } from 'react-query'
import {
render,
findByText,
fireEvent,
waitFor,
act,
} from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from '@mui/material/styles'
import { simulateDragAndDrop, retry } from '@/test-utils'
import { theme } from '@/app-design'
After:
import {
mockEndpoints,
renderInTestWrapper,
renderHook,
findByText,
actions,
waitFor,
act,
retry,
} from '@/test-utils'
Applicability
I use this principle with:
- Common UI components.
- Common infrastructure without a standardized interface e.g. logging.
- Testing utilities.
- Third-party SDKs.
I don’t use this principle with:
- Standard libraries, e.g. Array, Set, Map.
- Common infrastructure with a standardized interface e.g. OpenTelemetry.
- Very stable libraries e.g. Lodash.
- Base frameworks, e.g. React.
Dependency-inject IO functions
One rule of thumb that works really well for me is to perform dependency-injection on most IO operations.
Since I wanted the instant gratification of seeing my app working in the shortest amount of time possible, I didn’t bother doing test-driven development2, because that would take me longer time until my app is usable. I want to see that report already!
Still, I follow the above rule of thumb and made sure all IO operations are dependency-injected.
Another rule of thumb that I follow is to make every function that involves IO async from the start — even if it doesn’t use await at first.
- This way, should this function ever need to perform any async operation, I can just use
awaitwithout introducing any breaking changes or costly refactors.
Now that I get the gratification of seeing that my app is working, I realized “what a big mess of code I just created.” I needed to refactor — and that’s when I have a need to test my code…
Before:
export async function generateReport() {
const views = parseCSV(fs.readFileSync('views.csv'))
const events = parseCSV(fs.readFileSync('events.csv'))
// ...
fs.writeFileSync('engagement.csv', toCSV(engagement))
fs.writeFileSync('revenue.csv', toCSV(revenue))
console.log('Completed!')
}
After:
export async function generateReport({
fs: { readFileSync, writeFileSync } = fs,
logger: { log } = console,
} = {}) {
const views = parseCSV(readFileSync('views.csv'))
const events = parseCSV(readFileSync('events.csv'))
// ...
writeFileSync('engagement.csv', toCSV(engagement))
writeFileSync('revenue.csv', toCSV(revenue))
log('Completed!')
}
Thanks to the above rule, when I write tests,
- I can inject mock implementations of the IO functions, which lets me test my functions without stubbing or spying required.
- I can easily write acceptance tests that covers the whole process and still runs fast. These acceptance tests can provide more fidelity and confidence than unit tests.
- Also, since dependency is injected from the outside, it also makes writing unit tests easier.
Tests:
describe('the engagement report', () => {
it('...', async () => {
const fs = new FakeFS(fixtures)
await generateReport({ fs })
expect(fs.readFileSync('engagement.csv')).toMatchSnapshot()
})
})
Real-world examples
The
renderChartfunction takes a musical score and bundles of sound files, and renders it into an OGG audio file3.- Since everything related to IO is injected into the function, I can trivially write a smoke test for it.
export type RenderOptions = {
soundAssetsMetadata: SoundAssetsMetadata
chartData: ArrayBuffer
chartFilename: string
loadBemusepack: (path: string) => Promise<ArrayBuffer>
log?: (text: string) => void
}
export async function renderChart(options: RenderOptions)
The
OmniInputclass handles all supported input for the game (keyboard, gamepad, MIDI controller).- The
windowinstance is injected through its constructor, it becomes trivial to write unit tests for these input devices without any stubbing.
- The
export class OmniInput {
constructor(private _window = window, options = {}) {
// ...
}
// ...
}
Footnotes
Why did we switch from WebdriverIO to Selenium? At that time, we found a few problems with WebdriverIO:
- Sometimes it would error out with messages like “an unknown server-side error occurred” without showing the response from Selenium server.
- Upgrading to WebdriverIO v5 is painful due to a lot of breaking changes.
- WebdriverIO v5 ships with built-in type definitions, but the types were inaccurate and inconsistent with its actual API.
As of writing, WebdriverIO is at v7. Meanwhile, since 2019 selenium-webdriver’s API is much more stable and contains very little breaking changes. ↩
When I was just starting out, TDD was an excellent tool. By forcing me to write tests before I write actual code, it teaches me how to write testable code. Religiously applying TDD was a very useful experience for me, because I did not know how to write testable code before.
But now I know how to write testable code. Without any test, I can write code knowing exactly how I would test it later when I need to. Do I still need TDD? Well, sometimes.
Nowadays I do TDD when it is apparent that writing tests first would save me more time. I also try to write tests first when dealing with software regressions and legacy code. ↩
It uses Web Audio API and ffmpeg.wasm, but that’s an implementation detail ↩