Mock `Number.toLocaleString()` so it returns same results in tests independent of user locale

192 views Asked by At

We have code in our class that formats a number by transforming for example 1_000_000 to 1 M and 1_500_000 to 1.5 M respectively.

To achieve this we are using the following code:

export const shortNumberString = (number: number) => {
  if (!number) {
    return number === 0 ? '0' : '';
  }

  const i = Math.floor(Math.log(number) / Math.log(1000));

  return (
    Number((number / Math.pow(1000, i)).toFixed(1)).toLocaleString() +
    ' ' +
    ['', 'K', 'M', 'B'][i]
  ).trim();
};

The code works as expected and does what we want them to do, but we have developers from all over the world so based on the locale of the user this test fails. For example for some users it gets formatted to 1.5 M and for others to 1,5 M.

Normally we just ignore these failed tests, but they are super annoying because you always see these tests failing but have to activiely check to ignore them.

We have attempted the following solutions:

  • Add NODE_ICU

    Some users have recommended to use NODE_ICU package, which I added using

    yarn add -D NODE_ICU

    Afterwards I have added this to our test script

    "test": "NODE_ICU_DATA=node_modules/full-icu craco test",

    However, running yarn test does still result in errors. I have also tried to run this via command line but the same error appears.

  • Mock Number().toLocaleString()

    I have tried to mock Number.toLocaleString but without success. I have tried the following methods

    Ignore the return values as I just wanted it to fail with the given input so I'm sure it works with mocking

    1. First:

        (global as any).Number.toLocaleString = jest.fn(() => ({
          ...(global as any).Number,
          toLocaleString: jest.fn(() => '12.3.2019 13.47.47'),
        }));
      

      However, this does not work. It is not mocking anything.

    2. Same applies for this code

      (global as any).Number.toLocaleString = jest.fn(() => new Date('2019-04-07T10:20:30Z'));
      
    3. This:

        (global as any).Number = jest.fn(() => ({
          toLocaleString: jest.fn(() => '12.3.2019 13.47.47'),
        }));
      

      also fails, because of the following error

      Cannot read properties of undefined (reading 'toLocaleString')
      TypeError: Cannot read properties of undefined (reading 'toLocaleString')

    4. This:

        jest.mock('Number', () => ({
          ...jest.requireActual('Number'),
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
          toLocaleString: () => {
            return Promise.resolve('yolo');
          },
        }));
      

      does not work as there is no module named Number jest can mock. I have not found a way to mock

    5. This:

      jest
          .spyOn(global.Number, 'toLocaleString')
          .mockImplementation((number: string) => 10);
      

      also does not work as it tells me I cannot call the method spyOn like .spyOn(global.Number, 'toLocaleString')

  • Set env.LL etc.

    I have also tried to set the locales of env manually

      const env: any = process.env;
      env.LANG = 'en-GB';
      env.LANGUAGE = 'en-GB';
      env.LC_ALL = 'en-GB';
      env.LC_MESSAGES = 'en-GB';
      const language = env.LANG || env.LANGUAGE || env.LC_ALL || env.LC_MESSAGES;
      console.log(language);
    

    in the console.log it returns en-GB but the tests are still failing even though en-GB should return the correct format

I am out of ideas but cannot believe that it is impossible to mock this. However, everything I find on Google is mostly for Date mocking but not for Number mocking and I cannot apply these suggestions to Number formatting to get them working.

2

There are 2 answers

7
ibrahim tanyalcin On

The test can fail because the output of the function depends on the locale but the output of the test does not. Instead make the test depend on the locale too by:

  • returning an object (or whatever you prefer) from your formatter
  • use Int.NumberFormat within both test and the function
function invariantNumberFormat(n){
    n = +(n ?? '');
    const i = (Math.log10(n) | 0) / 3 | 0;
    return {
        base: parseFloat(n / Math.pow(10, i * 3)),
        post: ['', 'K', 'M', 'B'][i]
    }
}

function localeNumberFormat(nObj, formatter = Intl.NumberFormat()) {
    return formatter.format(nObj.base) + nObj.post
}

the invariantNumberFormat is locale independent. then in your test:

const val = invariantNumberFormat(100123);
Expect(localeNumberFormat(val)).toEqual(localeNumberFormat({
    "base": 100.123,
    "post": "K"
}))

The invariantNumberFormat is similar to your function, just a bit shorter. If you need to handle BigInt than do not use parseFloat. But the idea is the same. Configure the Intl formatter according to your needs, they provide a lot of options.

0
AudioBubble On

Consider using a regular expression for capturing locale-independent groups from the textual representation of the numbers. Ensure equality by comparing the captured groups with the expected ones.

const 
    number = 1500,
    string = toString(number),
    regex = /(\d*)\D?(\d)?\s*([KMB])?/,
    match = string.match(regex),
    expected = ['1', '5', 'K'];

assert.deepEqual(match.slice(1), expected)

The call match.slice(1) is required because the first group match[0] is, of course, the whole string. The length of the expected arrays must always coincide with the number 3 of groups.

const 
    number = 100,
    string = toString(number),
    regex = /(\d*)\D?(\d)?\s*([KMB])?/,
    match = string.match(regex),
    expected = ['100', undefined, undefined];

assert.deepEqual(match.slice(1), expected)