DEV Community

loading...

Mocking Made Simple

matheusrich profile image Matheus Richard Originally published at matheusrich.com on ・3 min read

TD;DR: Inject dependencies instead of mocking functions.

I was refactoring some React Native code the other day and ended up extracting the following function:

// src/screen.js

const logScreenOpened = async screenName => {
  try {
    await FirebaseLogger.logScreenView({screen: screenName});
  } catch (e) {
    console.error(`Could not log screen view. Error: ${e}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

When I got to testing, I wanted to change the logger behavior in each test case, but I was struggling with Jest mocks. I wanted to do something like this:

// tests/screen-test.js

describe('logScreenOpened', () => {
  it('logs a screen view', () => {
    // TODO: mock logger's `logScreenView`

    logScreenOpened('HomeScreen');

    // TODO: expect logger to have received `logScreenView` with `{ screen: 'HomeScreen' }`
  });

  it('logs to console if logger raises an error', () => {
    // TODO: mock logger's `logScreenView` to raise a error

    logScreenOpened(null);

    // TODO: expect console to have received .error
  });
});

const logScreenOpened = async screenName => {
  await FirebaseLogger.logScreenView({screen: screenName});
};
Enter fullscreen mode Exit fullscreen mode

It was taking more time than I wanted to, so I gave up and came up with other solution. Not a fancy solution, neither changing the mocking library. It was, in fact, a very old one (and very known among Java devs): Dependency Injection (DI for short).

Injecting dependencies

So, instead of accessing FirebaseLogger directly, we’ll provide it as an argument of our function:

// src/screen.js

const logScreenOpened = async (screenName, logger = FirebaseLogger) => {
  await logger.logScreenView({screen: screenName});
};
Enter fullscreen mode Exit fullscreen mode

This function is almost the same of the previous implementation, but receiving logger as an argument gives us some flexibility.

Here’s the thing: the logScreenOpened function don’t need to know what logger is. As long as it is an object that responds to logScreenView, we’re good. That’s the only thing we care about. This is known as duck-typing (or interfaces).

Back to testing: now we can change the logger implementation as we wish:

describe('logScreenOpened', () => {
  it('logs a screen view', () => {
    const logScreenViewMock = jest.fn();
    const fakeLogger = {logScreenView: logScreenViewMock};

    logScreenOpened('HomeScreen', fakeLogger);

    expect(logScreenViewMock).toHaveBeenCalledWith({screen: 'HomeScreen'});
  });

  it('logs to console if logger raises an error', () => {
    const logScreenViewMock = () => {
      throw 'Some error!';
    };
    console.error = jest.fn();

    logScreenOpened(null, fakeLogger);

    expect(console.error).toHaveBeenCalledWith(`Could not log screen view. Error: Some error!`);
  });
});
Enter fullscreen mode Exit fullscreen mode

OBS: We could’ve injected the console.error bit too, but for this post I decided to keep things simple.

Going ✨ fancy ✨

We don’t have to always inject the dependency directly in our methods. We could go fancy and have a logging module that gives us different loggers depending on which environment we’re working on. Something like this:

// config/log.js
import {FirebaseLogger} from 'firebase-logger';
const TestLogger = {
  // simplified code to run in tests
};

export const logger = process.env.NODE_ENV === 'test' ? TestLogger : FirebaseLogger;
Enter fullscreen mode Exit fullscreen mode

And in the app code we just import it:

// src/screen.js

import {logger} from 'config/log';

const logScreenOpened = async screenName => {
  await logger.logScreenView({screen: screenName});
};
Enter fullscreen mode Exit fullscreen mode

Speeding up

Refactors like this are nice when you want to to improve test performance too. Our fake logger is very lightweight, so those test are blazing fast! Use this to your advantage!

Let’s say we have an function that does some heavy computation:

function doStuff() {
  Lib.veryHeavyComputation('some', 'params');

  doMoreStuff();
}
Enter fullscreen mode Exit fullscreen mode

We don’t want our tests to be slow, so let’s do the same thing here: inject dependencies.

function doStuff(lib = Lib) {
  lib.veryHeavyComputation('some', 'params');

  doMoreStuff();
}
Enter fullscreen mode Exit fullscreen mode

So, in our tests we swap the implementation:

describe('doStuff', () => {
  const fastLib = {veryHeavyComputation: jest.fn()};

  doStuff(fastLib);

  expect(fastLib).toHaveBeenCalledWith('some', 'params');

  // other expectations
});
Enter fullscreen mode Exit fullscreen mode

Now we have fast tests but guaranteeing the same API!

Wrap up

Don’t be afraid of this kind of code! As I said, there’s nothing new here. Is an old techniche used everywhere. In fact, Elixir’s guides encourages this very approach! They call it “mock-as-a-noun” (in opposition of “mock-as-a-verb”).

One think to keep in mind: we must keep the API of our fake logger in sync with the original one. This is important because we don’t want our tests passing but our production code failing. So is it good to have a integration test covering the usage of that mock too!

That’s it, happy mocking! (wait, that’s a verb!)

Discussion (1)

Collapse
allannobre profile image
Forem Open with the Forem app