George Katsanos
George Katsanos

Reputation: 14185

How to spy and assert non exported methods

I've created a script that receives JSON data from a file, parses it, reformats and groups em and then returns an aggregated table, which I am trying to write tests for.

The code structure

├── Readme.md
├── input.json
├── main.ts
├── services
│   ├── calculateTransactionTotals.js
│   └── validateInput.js
└── tests
    ├── mocks.js
    └── tests.test.js
// main.js
import { validateInput, readInputFile } from './services/validateInput.js'
import { calculateTransactionTotals } from './services/calculateTransactionTotals.js'

validateInput()

const content = await readInputFile()

calculateTransactionTotals(content)
// calculateTransactionTotals

import groupby from 'https://esm.sh/lodash.groupby'
import moment from 'https://esm.sh/moment'

export function calculateTransactionTotals(content) {
  groupTransactionsByUserAndCurrency(content) // omitting this for brevity, basically does a bunch of `groupby`s
  prepareData(groupTransactionsByUserAndCurrency(content), content)
}

function prepareData(transactionsGroupedByUserIdAndCurrency, content) {
  const result = {}
  // some logic here, doesnt matter
  printTable(result)
  return result
}

function printTable(result) {
  console.table(result, ['GBP', 'EUR', 'USD', 'last-activity'])
}

export const _internals = { prepareData }

My main idea is to run calculateTransactionTotals and assert prepareData was run and returned the expected value. But I'm struggling to get the spy to run.

import { calculateTransactionTotals, _internals } from '../services/calculateTransactionTotals.js'
import data from './mocks.js'
import { assertSpyCalls, spy } from 'https://deno.land/[email protected]/testing/mock.ts'

Deno.test('is the expected result returned', () => {
  const prepareDataSpy = spy(_internals, 'prepareData')
  calculateTransactionTotals(data)
  assertSpyCalls(prepareDataSpy, 1)
})

Any other proposals on how to test this functionality would be welcome and accepted as a correct answer.

Upvotes: 0

Views: 473

Answers (1)

jsejcksn
jsejcksn

Reputation: 33856

First, some contextual observations:

In the module services/calculateTransactionTotals.js you have defined and exported a function calculateTransactionTotals. In the same module, you've defined a function prepareData which is not directly exported. However, you have exported an object named _internals which references the prepareData function on a property by the same name (making it a method).

The function calculateTransactionTotals contains an internal, direct reference to the function prepareData, which makes it a closure:

export function calculateTransactionTotals(content) {
  groupTransactionsByUserAndCurrency(content) // omitting this for brevity, basically does a bunch of `groupby`s
  prepareData(groupTransactionsByUserAndCurrency(content), content)
}

This function reference is immutable. Spying on it does not replace it in the ES module graph.

The way that the spy function works is to create and return a new "spy" function which is essentially a proxy to (or "wrapper" around) the original. In the case of a spied method, it also overwrites the method property on the target object.


The example you provided has quite a bit of code which is unrelated to the problem, so I'll create a minimal, reproducible example below in order to keep the focus on the problematic code:

mod.ts:

// prepareData
function privateFn() {}

// calculateTransactionTotals
export function publicFn() {
  privateFn();
}

export const _internals = { privateFn };

mod.test.ts:

import {
  assertSpyCalls,
  spy,
} from "https://deno.land/[email protected]/testing/mock.ts";
import { _internals, publicFn } from "./mod.ts";

Deno.test("private function is invoked once", () => {
  const spyFn = spy(_internals, "privateFn");
  publicFn();
  assertSpyCalls(spyFn, 1);
});

When running the test (copied from the code in your question), this is the output:

% deno test
Check file:///Users/deno/so-73911434/mod.test.ts
running 1 test from ./mod.test.ts
private function is invoked once ... FAILED (10ms)

 ERRORS 

private function is invoked once => ./mod.test.ts:7:6
error: AssertionError: spy not called as much as expected:


    [Diff] Actual / Expected


-   0
+   1

    throw new AssertionError(message);
          ^
    at assertSpyCalls (https://deno.land/[email protected]/testing/mock.ts:489:11)
    at file:///Users/deno/so-73911434/mod.test.ts:10:3

 FAILURES 

private function is invoked once => ./mod.test.ts:7:6

FAILED | 0 passed | 1 failed (60ms)

error: Test failed

When the privateFn method is spied on in the test, that doesn't update the internal reference to privateFn in the publicFn closure, which is your testing goal.

In order to do that, you'll need to reference privateFn as a method on the _internals object instead, like this:

mod.ts:

function privateFn() {}

export const _internals = { privateFn };

export function publicFn() {
  // Now invoking a method on `_internals`:
  _internals.privateFn();
}

Now, when the test is run, the spy call replaces the method property with the spy, and the spy function is invoked by publicFn (instead of the original function):

% deno test
Check file:///Users/deno/so-73911434/mod.test.ts
running 1 test from ./mod.test.ts
private function is invoked once ... ok (8ms)

ok | 1 passed | 0 failed (48ms)

One last note:

Be sure to reverse your spy mutation after you're finished, so that the original object is restored to its former state. This means adding one more line in the example test:

mod.test.ts:

import {
  assertSpyCalls,
  spy,
} from "https://deno.land/[email protected]/testing/mock.ts";
import { _internals, publicFn } from "./mod.ts";

Deno.test("private function is invoked once", () => {
  const spyFn = spy(_internals, "privateFn");
  publicFn();
  assertSpyCalls(spyFn, 1);
  // Restore the original method to its property on the `_internals` object:
  spyFn.restore();
});

Upvotes: 1

Related Questions