The 100% Code Coverage Myth – Hacker Noon

Part 2: Lead by Example

Around the time I was getting pretty confident with my new testing stack, another tool was introduced to the market which many claimed made unit testing even simpler: Jest.

Jest is an automated testing framework pioneered by Facebook.

Jest does a really awesome job condensing the previous libraries I had used into a single coherent framework that is a test-runner, as well as a set of APIs for mocking, spying, and assertions. Beyond providing a single library with all your unit-testing needs, Jest does a great job at simplifying some of the concepts and patterns as well with powerful and simple mocking.

Because I think Jest is simpler to use and to understand, I’m going to stick with Jest for examples.

If you’re just joining me on this article, that’s fine —what you’ve read so far is meant to stand on it’s own. However, I’ve been documenting the process of building a React application using Parcel with Streaming SSR, and this article is going to continue where the last part left off.

In my last article, linked below, I showed how to set up Jest with code coverage and said in the next article I’d show how to get the coverage up to 100%.

I figured the best way to demonstrate 100% coverage is showing how to get there. Throughout the journey we will likely discover several places where code can be refactored to be more testable. So, I’ll continue where I left off, and get coverage of this project to 100%, and show what refactors to make, where to use partial application and dependency injection, and what to mock along the way when coverage is difficult to get.

So… Let’s get started. Here’s the project I’ll be working on:

The project has a react app in the app folder, and a server folder which contains the SSR logic. Let’s start with the application tests.

Application Tests

In the last article, after configuring Jest, I got started with a simple test for a simple component. I have several React components that are equally as simple.

This is one of the reasons that functional components are really powerful. Functions are easier to test than classes. They don’t have state — instead they have inputs and outputs. Given input X, they have output Y. When there is state it can be stored externally to the component.

The new React Hooks API is nice in this regard because it encourages making functional components, and has an easily mockable mechanism to provide state to the component. Redux provides the same benefit in regards to testing.

Let’s start by knocking out the rest of the simple components. We basically just need to render them and maybe check that some important pieces of info are rendered.

I usually put code inline in the articles, but there’s not really anything new in these tests, so instead I’ve decided to link to the actual commits and only show one full example:

Let’s take a look at the About page:

import React from 'react'
import Helmet from 'react-helmet-async'
import Page from '../components/Page'
const About = () => (


About Page
    
This is the about page


)
export default About

And it’s tests:

import React from 'react'
import { shallow } from 'enzyme'
import About from 'app/pages/About.jsx'
describe('app/pages/About.jsx', () => {
it('renders About page', () => {
expect(About).toBeDefined()
const tree = shallow()
expect(tree.find('Page')).toBeDefined()
expect(
tree
.find('Helmet')
.find('title')
.text()
).toEqual('About Page')
expect(tree.find('div').text()).toEqual('This is the about page')
})
})

All of the tests in the following commits are very similar:

As you can see, just making sure our component renders is enough for these components to get 100% coverage. More detailed interactions are better left to E2E tests, which is out of scope for the current article.

The next component, app/App.jsx is slightly more complex. After writing a rendering test, you’ll notice there is still an unreachable anonymous function that is used in the Router to render the About page.

In order to access and test this, we want to make a small refactor, extracting the function to a named function so we can export it and test it out.

Now it is easy to test:

Because we have another set of tests for the About page above, we’ll leave its more specific tests to live there, and just need to check that it renders here.

And with that, the only file left to test in our application is app/client.js, and then we can move on to finishing up server side tests.

Let’s take a look at the code:

import React from 'react'
import ReactDOM from 'react-dom'
import { HelmetProvider } from 'react-helmet-async'
import { BrowserRouter } from 'react-router-dom'
import { rehydrateMarks } from 'react-imported-component'
import importedComponents from './imported' // eslint-disable-line
import App from './App'
const element = document.getElementById('app')
const app = (





)
// In production, we want to hydrate instead of render
// because of the server-rendering
if (process.env.NODE_ENV === 'production') {
// rehydrate the bundle marks
rehydrateMarks().then(() => {
ReactDOM.hydrate(app, element)
})
} else {
ReactDOM.render(app, element)
}
// Enable Hot Module Reloading
if (module.hot) {
module.hot.accept()
}

The first thing I notice is that there is a reliance on global variables — document, process and module. The second thing is that nothing is exported so it may be hard to run multiple times with different inputs.

We can remedy this with a few refactors:

  1. Wrap up all of the logic into a function that we can export. This function will accept an options objects with all of its dependencies. This is called dependency injection. This will allow us to easily pass along mock versions of a bunch of things if we so choose.
  2. We have an anonymous function in production mode after rehydrating which should be extracted to a named function.

We also will want to mock a few of the external modules: react-dom, react-imported-component, and app/imported.js. Modules are a form of dependency injection themselves.

First here’s the newly refactored file with the changes in bold:

import React from 'react'
import ReactDOM from 'react-dom'
import { HelmetProvider } from 'react-helmet-async'
import { BrowserRouter } from 'react-router-dom'
import { rehydrateMarks } from 'react-imported-component'
import importedComponents from './imported' // eslint-disable-line
import App from './App'
// use "partial application" to make this easy to test
export const hydrate = (app, element) => () => {
ReactDOM.hydrate(app, element)
}
export const start = ({ 
isProduction,
document,
module,
hydrate
}) => {

const element = document.getElementById('app')
const app = (





)
  // In production, we want to hydrate instead of render
// because of the server-rendering
if (isProduction) {
// rehydrate the bundle marks from imported-components,
// then rehydrate the react app
rehydrateMarks().then(hydrate(app, element))
} else {
ReactDOM.render(app, element)
}
  // Enable Hot Module Reloading
if (module.hot) {
module.hot.accept()
}
}
const options = {
isProduction: process.env.NODE_ENV === 'production',
document: document,
module: module,
hydrate
}
start(options)

Now we can actually access and test start with a variety of options as well as testing hydrate independently of the startup logic.

The tests are a bit long, so I’ve put comments inline to explain what is going on. Here are tests for the file:

import React from 'react'
import fs from 'fs'
import path from 'path'
import { start, hydrate } from 'app/client'
import { JSDOM } from "jsdom"
jest.mock('react-dom')
jest.mock('react-imported-component')
jest.mock('app/imported.js')
// mock DOM with actual index.html contents
const pathToIndex = path.join(process.cwd(), 'app', 'index.html')
const indexHTML = fs.readFileSync(pathToIndex).toString()
const DOM = new JSDOM(indexHTML)
const document = DOM.window.document
// this doesn't contribute to coverage, but we
// should know if it changes as it would
// cause our app to break
describe('app/index.html', () => {
it('has element with id "app"', () => {
const element = document.getElementById('app')
expect(element.id).toBe('app')
})
})
describe('app/client.js', () => {
  // Reset counts of mock calls after each test
afterEach(() => {
jest.clearAllMocks()
})
  describe('#start', () => {
it('renders when in development and accepts hot module reloads', () => {
// this is mocked above, so require gets the mock version
// so we can see if its functions are called
const ReactDOM = require('react-dom')

// mock module.hot
const module = {
hot: {
accept: jest.fn()
}
}

      // mock options
const options = {
isProduction: false,
module,
document
}
      start(options)
expect(ReactDOM.render).toBeCalled()
expect(module.hot.accept).toBeCalled()
})

it('hydrates when in production does not accept hot module reloads', () => {
const ReactDOM = require('react-dom')
const importedComponent = require('react-imported-component')
importedComponent.rehydrateMarks.mockImplementation(() => Promise.resolve())

      // mock module.hot
const module = {}
      // mock rehydrate function
const hydrate = jest.fn()
      // mock options
const options = {
isProduction: true,
module,
document,
hydrate
}
      start(options)
expect(ReactDOM.render).not.toBeCalled()
expect(hydrate).toBeCalled()
})
  })
  describe('#hydrate', () => {
it('uses ReactDOM to hydrate given element with an app', () => {
const ReactDOM = require('react-dom')
const element = document.getElementById('app')
const app = (
)
const doHydrate = hydrate(app, element)
      expect(typeof doHydrate).toBe('function')
      doHydrate()
expect(ReactDOM.hydrate).toBeCalledWith(app, element)
})
})
})

Now when we run our tests, we should have 100% coverage of the app folder, aside from app/imported.js which is a generated file, and doesn’t make sense to test as it could generate differently in future version.

Let’s update our jest config to ignore it from coverage statistics, and check out the results.

In jest.config add:

"coveragePathIgnorePatterns": [
"/app/imported.js",
"/node_modules/"
]

Now when we run npm run test we get the following results.

Something that I want to point out, is that while I’m developing tests, I’m usually using “watch” mode to do so, so as tests are changed they are automatically re-run.

With application tests done, let’s move on to the server.

Server Tests

In the previous article I wrote tests for one application file, as well as one server file, so we already have tests for server/index.js. Now we need to test the three remaining files in server/lib.

Let’s start with server/lib/client.js:

import fs from 'fs'
import path from 'path'
import cheerio from 'cheerio'
export const htmlPath = path.join(process.cwd(), 'dist', 'client', 'index.html')
export const rawHTML = fs.readFileSync(htmlPath).toString()
export const parseRawHTMLForData = (template, selector = '#js-entrypoint') => {
const $template = cheerio.load(template)
let src = $template(selector).attr('src')
  return {
src
}
}
const clientData = parseRawHTMLForData(rawHTML)
const appString = '
'
const splitter = '###SPLIT###'
const [startingRawHTMLFragment, endingRawHTMLFragment] = rawHTML
.replace(appString, `${appString}${splitter}`)
.split(splitter)
export const getHTMLFragments = ({ drainHydrateMarks }) => {
const startingHTMLFragment = `${startingRawHTMLFragment}${drainHydrateMarks}`
return [startingHTMLFragment, endingRawHTMLFragment]
}

First off, I’ve noticed there’s a pretty big block of code that isn’t even used in the project from a previous abandoned strategy. Everything from export const parseRawHTMLForData through const clientData.

I’m gonna start by deleting that. The less code there is, the less places bugs can exist. There’s also a couple of exports which I never made use of which can stay private to the module.

Here’s the updated file:

import fs from 'fs'
import path from 'path'
const htmlPath = path.join(process.cwd(), 'dist', 'client', 'index.html')
const rawHTML = fs.readFileSync(htmlPath).toString()
const appString = '
'
const splitter = '###SPLIT###'
const [startingRawHTMLFragment, endingRawHTMLFragment] = rawHTML
.replace(appString, `${appString}${splitter}`)
.split(splitter)
export const getHTMLFragments = ({ drainHydrateMarks }) => {
const startingHTMLFragment = `${startingRawHTMLFragment}${drainHydrateMarks}`
return [startingHTMLFragment, endingRawHTMLFragment]
}

It looks like one test should probably do it for this one. However, there’s a slight hiccup in the plan: this file depends on the build being run before as it reads in the generated build.

Technically this makes sense, because you’d never try to render the app on the server without having a built app to render.

Given that constraint I’d say it’s ok, and probably isn’t worth the effort to refactor given we can just make sure our pipeline calls build before test. If we wanted to have really pure unit isolation we might consider refactoring a bit more as technically the whole application is a dependency of SSR, so it could be mocked. On the other hand, using the actual build is probably more useful anyway. You’ll frequently encounter trade-offs like this throughout the process of writing tests.

With that being said, here is the test to get full coverage for this module:

import { getHTMLFragments } from 'server/lib/client.js'
describe('client', () => {
it('exists', () => {
const drainHydrateMarks = ''
const [start, end] = getHTMLFragments({ drainHydrateMarks })
expect(start).toContain('')
expect(start).toContain(drainHydrateMarks)
expect(end).toContain('script id="js-entrypoint"')
})
})

And the commits: fix: remove unused code for parsing template, test: server/lib/client tests.

Next, server/lib/server.js is quite tiny, so let’s knock that one out. Here is its code to refresh your memory, or if you’re just joining us now:

import express from 'express'
export const server = express()
export const serveStatic = express.static

And the tests:

import express from 'express'
import { server, serveStatic } from 'server/lib/server.js'
describe('server/lib/server', () => {
it('should provide server APIs to use', () => {
expect(server).toBeDefined()
expect(server.use).toBeDefined()
expect(server.get).toBeDefined()
expect(server.listen).toBeDefined()
expect(serveStatic).toEqual(express.static)
})
})

Seems how we are basically just deferring all the responsibility to express, and we expect express to provide this contract, we can just simply make sure it does, and it doesn’t really make sense to go beyond this.

Finally, we have only one more file to test: server/lib/ssr.js.

Here’s our ssr module:

import React from 'react'
import { renderToNodeStream } from 'react-dom/server'
import { HelmetProvider } from 'react-helmet-async'
import { StaticRouter } from 'react-router-dom'
import { ServerStyleSheet } from 'styled-components'
import { printDrainHydrateMarks } from 'react-imported-component'
import log from 'llog'
import through from 'through'
import App from '../../app/App'
import { getHTMLFragments } from './client'
// import { getDataFromTree } from 'react-apollo';
export default (req, res) => {
const context = {}
const helmetContext = {}
  const app = (





)
  try {
// If you were using Apollo, you could fetch data with this
// await getDataFromTree(app);
    const sheet = new ServerStyleSheet()
const stream = sheet.interleaveWithNodeStream(
renderToNodeStream(sheet.collectStyles(app))
)
    if (context.url) {
res.redirect(301, context.url)
} else {
const [startingHTMLFragment, endingHTMLFragment] = getHTMLFragments({
drainHydrateMarks: printDrainHydrateMarks()
})
res.status(200)
res.write(startingHTMLFragment)
stream
.pipe(
through(
function write (data) {
this.queue(data)
},
function end () {
this.queue(endingHTMLFragment)
this.queue(null)
}
)
)
.pipe(res)
}
} catch (e) {
log.error(e)
res.status(500)
res.end()
}
}

It’s a bit long, and there are a few paths to execute. I do want to make a couple small refactors that will make isolation a bit easier, such as extracting the logic to generate the app out to a separate function, and using partial application to be able to inject the application stream renderer so we can easily mock some redirects.

Also write and end are a bit tough to get to, so we can pull those out higher using partial application as well.

Here’s an updated version:

import React from 'react'
import { renderToNodeStream } from 'react-dom/server'
import { HelmetProvider } from 'react-helmet-async'
import { StaticRouter } from 'react-router-dom'
import { ServerStyleSheet } from 'styled-components'
import { printDrainHydrateMarks } from 'react-imported-component'
import log from 'llog'
import through from 'through'
import App from '../../app/App'
import { getHTMLFragments } from './client'
// import { getDataFromTree } from 'react-apollo';
const getApplicationStream = (originalUrl, context) => {
const helmetContext = {}
const app = (





)
  const sheet = new ServerStyleSheet()
return sheet.interleaveWithNodeStream(
renderToNodeStream(sheet.collectStyles(app))
)
}
export function write (data) {
this.queue(data)
}
// partial application with ES6 is quite succinct
// it just means a function which returns another function
// which has access to values from a closure
export const end = endingHTMLFragment =>
function end () {
this.queue(endingHTMLFragment)
this.queue(null)
}
export const ssr = getApplicationStream => (req, res) => {
try {
// If you were using Apollo, you could fetch data with this
// await getDataFromTree(app);
    const context = {}
const stream = getApplicationStream(req.originalUrl, context)
    if (context.url) {
return res.redirect(301, context.url)
}
    const [startingHTMLFragment, endingHTMLFragment] = getHTMLFragments({
drainHydrateMarks: printDrainHydrateMarks()
})
    res.status(200)
res.write(startingHTMLFragment)
stream.pipe(through(write, end(endingHTMLFragment))).pipe(res)
} catch (e) {
log.error(e)
res.status(500)
res.end()
}
}
const defaultSSR = ssr(getApplicationStream)
export default defaultSSR

Here’s a link to look at the diffs in Github: chore: refactor ssr to break it up / make it easier to read, and chore: refactor ssr more.

Now let’s write some tests. We’ll need to set the jest-environment for this file specifically for node otherwise the styled-components portion will not work.

/**
* @jest-environment node
*/
import defaultSSR, { ssr, write, end } from 'server/lib/ssr.js'
jest.mock('llog')
const mockReq = {
originalUrl: '/'
}
const mockRes = {
redirect: jest.fn(),
status: jest.fn(),
end: jest.fn(),
write: jest.fn(),
on: jest.fn(),
removeListener: jest.fn(),
emit: jest.fn()
}
describe('server/lib/ssr.js', () => {
describe('ssr', () => {
it('redirects when context.url is set', () => {
const req = Object.assign({}, mockReq)
const res = Object.assign({}, mockRes)
const getApplicationStream = jest.fn((originalUrl, context) => {
context.url = '/redirect'
})
const doSSR = ssr(getApplicationStream)
      expect(typeof doSSR).toBe('function')
doSSR(req, res)
expect(res.redirect).toBeCalledWith(301, '/redirect')
})
    it('catches error and logs before returning 500', () => {
const log = require('llog')
const req = Object.assign({}, mockReq)
const res = Object.assign({}, mockRes)
const getApplicationStream = jest.fn((originalUrl, context) => {
throw new Error('test')
})
const doSSR = ssr(getApplicationStream)
expect(typeof doSSR).toBe('function')
doSSR(req, res)
expect(log.error).toBeCalledWith(Error('test'))
expect(res.status).toBeCalledWith(500)
expect(res.end).toBeCalled()
})
})
  describe('defaultSSR', () => {
it('renders app with default SSR', () => {
const req = Object.assign({}, mockReq)
const res = Object.assign({}, mockRes)
defaultSSR(req, res)
expect(res.status).toBeCalledWith(200)
expect(res.write.mock.calls[0][0]).toContain('')
expect(res.write.mock.calls[0][0]).toContain(
'window.___REACT_DEFERRED_COMPONENT_MARKS'
)
})
})
  describe('#write', () => {
it('write queues data', () => {
const context = {
queue: jest.fn()
}
const buffer = new Buffer.from('hello')
write.call(context, buffer)
expect(context.queue).toBeCalledWith(buffer)
})
})
  describe('#end', () => {
it('end queues endingFragment and then null to end stream', () => {
const context = {
queue: jest.fn()
}
const endingFragment = ''
const doEnd = end(endingFragment)
doEnd.call(context)
expect(context.queue).toBeCalledWith(endingFragment)
expect(context.queue).toBeCalledWith(null)
})
})
})

As this file was a bit more complex than some of the others it took a few more tests to hit all of the branches. Each function is wrapped in its own describe block for clarity.

Here is the commit on Github: test: ssr unit tests.

Now, when we run our tests we have 100% coverage!

Finally, before wrapping things up, I’m going to make a small change to my jest.config to enforce 100% coverage. Maintaining coverage is much easier than getting to it the first time. Many of the modules we tested will hardly ever change.

  "coverageThreshold": {
"global": {
"branches": 100,
"functions": 100,
"lines": 100,
"statements": 100
}
},

And done! Here’s the commit on Github: chore: require 100% coverage.

Conclusion

My goal for this article was to demonstrate the techniques needed to be able to refactor your code, or isolate units using mocks and dependency injection to make tough to test code easy to reach and discuss some of the merits of reaching 100% coverage. Also, using TDD from a starting point is a lot easier.

I’m a firm believer that if 100% coverage is hard to reach it’s because code needs to be refactored.

In many cases an E2E test is going to be a better test for certain things. A Cypress.io suite on top of this which loads the app and clicks around would go a long way in increasing our confidence even further.

I believe working in a codebase that has 100% coverage does a great job in increasing the confidence you have in each release and therefore increasing the velocity which you can make and detect breaking changes.

As always, if you’ve found this useful, please leave some claps, follow me, leave a star on the GitHub project, and/or share on social networks!

In the next part, coming soon, we will add a production ready Dockerfile, and explore how using nothing but another Dockerfile we can alternatively package our application as a static site served with Nginx, and some tradeoffs between the two approaches.

Best,
Patrick Lee Scott

read original article here