Using Webpack Environment Variables in Jest Tests


In this video we cover two issues inside our code as it stands currently:

// /src/connectivity/api.auth.js

import asyncFetch from './async-fetch';

export async function login(username, password) {

  const url = 'http://api.rest-user-api.dev/app_acceptance.php/login';

  const requestConfig = {
    method: 'POST',
    mode: 'cors',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      username,
      password
    })
  };

  const response = await asyncFetch(url, requestConfig);

  return await response.json();
}

Firstly, we have hardcoded the URL. This is fine during development, and as we only have one function that talks to our API, not really that big of a problem. Yet.

Of course, we won't want to use this URL in production.

We're going to want a way of differing the 'base' part of this URL - http://api.rest-user-api.dev/app_acceptance.php - depending on the environment we are running in - production, development, or any other.

Secondly, we have this chunk of requestConfig which is highly likely to remain - at least in part - very similar across every request.

We most likely always want to use cors mode. We have been more secure with our CORS setup on the server side.

Likely almost every request that sends in some body content - POST, PUT, PATCH - are going to be in the format of JSON. Setting the Content-Type every single time we make a request is annoying. Again, this could be considered the default config, and we will override as appropriate.

In the previous video we ensured our code is tested, so now we can start changing things with the confidence that as long as our tests pass at the end of this, we haven't broken any functionality.

Going Global Down In Acapulco

Ok, so firstly, what I want to do is rather than use the hardcoded url path, I want to split out the piece that changes, from the piece that stays the same.

In our development environment, the 'base' part of our URL - the piece that only changes per environment (dev, prod, etc) - is:

http://api.rest-user-api.dev/app_acceptance.php

In production, our base URL will be totally different:

https://api.something.com

Now, note that I don't include a trailing slash here:

https://api.something.com

// not

https://api.something.com/

This is purely my preference, but I prefer to start my URLs inside the function calls with a slash:

const url = API_BASE_URL + '/login';

// instead of

const url = API_BASE_URL + 'login';

Feel free to choose whatever style you prefer, of course.

Ok, so you may be now wondering why I'm using the old style API_BASE_URL + '/login' syntax, over the visually preferable ES6 Template String syntax:

const url = API_BASE_URL + '/login';

// instead of

const url = `${API_BASE_URL}/login`;

I prefer the second one - the template string syntax. But unfortunately I have had a few issues with template strings when using Webpack's DefinePlugin to define global constants, as we are about to see.

Now, we have two problems to address.

Firstly, where is that API_BASE_URL even coming from?

And secondly, how can we work with this in our tests?

Ok, so first problem. Where do we define API_BASE_URL? After all, we can't define it in our code as its value is environment specific.

Well, Webpack does have us covered here. We can use DefinePlugin as I previous alluded to:

// /webpack.config.dev.js

export default {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('development'), // Tells React to build in either dev or prod modes. https://facebook.github.io/react/downloads.html (See bottom)
      __DEV__: true,
      API_BASE_URL: JSON.stringify('http://api.rest-user-api.dev/app_acceptance.php')
    }),

I've snipped out all the unrelated config here.

Critically, make sure you JSON.stringify this string, or you will have a bad time:

ERROR in ./src/connectivity/api.auth.js
Module parse failed: /Users/Shared/Development/react-registration/node_modules/babel-loader/index.js!/Users/Shared/Development/react-registration/src/connectivity/api.auth.js Unexpected token (1:5)
You may need an appropriate loader to handle this file type.
SyntaxError: Unexpected token (1:5)
    at Parser.pp$4.raise (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:2221:15)
    at Parser.pp.unexpected (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:603:10)
    at Parser.pp.expect (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:597:28)
    at Parser.pp$3.parseParenAndDistinguishExpression (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1852:40)
    at Parser.pp$3.parseExprAtom (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1796:19)
    at Parser.pp$3.parseExprSubscripts (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1715:21)
    at Parser.pp$3.parseMaybeUnary (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1692:19)
    at Parser.pp$3.parseExprOps (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1637:21)
    at Parser.pp$3.parseMaybeConditional (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1620:21)
    at Parser.pp$3.parseMaybeAssign (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1597:21)
    at Parser.pp$3.parseExpression (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:1573:21)
    at Parser.pp$1.parseStatement (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:727:47)
    at Parser.pp$1.parseTopLevel (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:638:25)
    at Parser.parse (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:516:17)
    at Object.parse (/Users/Shared/Development/react-registration/node_modules/acorn/dist/acorn.js:3098:39)
    at Parser.evaluate (/Users/Shared/Development/react-registration/node_modules/webpack/lib/Parser.js:927:18)
    at Parser. (/Users/Shared/Development/react-registration/node_modules/webpack/lib/DefinePlugin.js:71:31)
 @ ./src/sagas/auth.saga.js 15:11-46

I leave this here in the slim hope it helps someone else along the way who made the same mistake that I did. Quite a tricky one to track down.

Whilst you're at it, be sure to update your webpack.config.prod.js in a similar fashion with your real API URL.

After doing this you will need to restart your development server to reload your new webpack config. Once done, you should be ok to access API_BASE_URL as a global constant. Your IDE will moan about this, so help it along with a little snippet:

export async function login(username, password) {

  /* global API_BASE_URL */
  const url = API_BASE_URL + '/login';

At this point, your development webserver should be up and running, unaware you've extracted the base URL to a global constant. Test this by trying to log in, and you should have no issues.

However, if you look at your Jest test output, things aren't quite as rosy:

FAIL  __tests__/connectivity/api.auth.js
  ● API Auth › login › has a happy path

    ReferenceError: API_BASE_URL is not defined

And again, this is due to Jest not using Webpack. Therefore this constant is indeed not defined when Jest runs our tests.

To fix this problem, my preferred way is to add a jest section to package.json. You could also do this with a .jestrc file. I found this way the easiest for me, personally:

// /package.json

  "jest": {
    "globals": {
      "API_BASE_URL": "blah"
    }
  }

Restart your test suite note (yarn test:watch) and you should have a failing test now with a slightly different message:

Expected value to equal:
  "blah/login"
Received:
  "http://api.rest-user-api.dev/app_acceptance.php/login"

Which is because we've hardcoded the URL into our login function. Fixing that in our test is as easy as in our code:

// /__tests__/connectivity/api.auth.js

describe('API Auth', () => {
  describe('login', () => {
    it('has a happy path', async () => {

      // * snip *

      /* global API_BASE_URL */
      const expectedUrl = API_BASE_URL + '/login';

And with that, our tests should be back to passing.

Base Request Config

Even though this could be considered a really basic change, I'm still going to go ahead and write a test for this, as why not?

// /__tests__/connectivity/baseRequestConfig.js

import {getBaseRequestConfig} from '../../src/connectivity/baseRequestConfig';

describe('Base Request Config', () => {

  it('returns a basic default config', () => {

    const result = getBaseRequestConfig();

    expect(result).toEqual({
      method: 'GET',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      }
    });
  });
});

We don't have a file called baseRequestConfig.js as of yet, but when we do, we now expect it to have a function called getBaseRequestConfig, which will return a simple object with our default request configuration:

{
  method: 'GET',
  mode: 'cors',
  headers: {
    'Content-Type': 'application/json'
  }
}

Note that we default to GET requests. This is the default anyway, but best to be explicit.

The implementation for this is as straightforward:

// /src/connectivity/baseRequestConfig.js

export const getBaseRequestConfig = () => {
  return {
    method: 'GET',
    mode: 'cors',
    headers: {
      'Content-Type': 'application/json'
    }
  };
};

With this in place, we can now pull in this base request config to login, and then override as needed to be specific to our current call:

// /src/connectivity/api.auth.js

import asyncFetch from './async-fetch';
import {getBaseRequestConfig} from './baseRequestConfig';

export async function login(username, password) {

  /* global API_BASE_URL */
  const url = API_BASE_URL + '/login';

  const baseRequestConfig = getBaseRequestConfig();

  const requestConfig = Object.assign({}, baseRequestConfig, {
    method: 'POST',
    body: JSON.stringify({
      username,
      password
    })
  });

  const response = await asyncFetch(url, requestConfig);

  return await response.json();
}

In using Object.assign we can take an empty object, then apply the contents of baseRequestConfig onto that empty object, and then onto the result of that, override with our specific configuration. It's a really nice, and self documenting approach to what we are trying to achieve.

Our tests should all still pass, and our code should therefore be behaving as expected :) Nice.

Code For This Course

Get the code for this course.

Code For This Video

Get the code for this video.

Episodes