Vad är det korrekt sättet att stubba beroenden när man enhetstestar i TypeScript?


Jag migrerar ett set med Firebase Cloud Functions från Javascript till Typescript. Med JS kunde jag enhetstesta med mocha, chai och sinon och stubba ut olika databasberoenden för testning. Med TS stöter jag på problem och oväntat beteende som jag inte förstår.

Mönstret är ungefär: mainFunction anropar helperFunction som anropar nestedHelperFunction.

Jag vill stubba eller spionera på nestedHelperFunction i mina tester när jag anropar mainFunction med några testdata.

mainFunction finns i en index.ts och helperFunction och nestedHelperFunction finns i något som en utils.ts-fil.

Exempel på konstigt beteende

// index.ts
import * as utils from './utils';


export async function mainFunction() {
    console.log('Starting mainFunction...');

    const promiseResults = await Promise.all([
        Promise.resolve('One'),
        utils.helperFunction(),
        Promise.resolve('Three'),
    ]);

    console.log(promiseResults);
    return 1;
}
// utils.ts
export async function helperFunction() {
    const newString = await nestedHelperFunction();
    return 'helperFunction Result | ' + newString;
}

export async function nestedHelperFunction() {
    return '***Nested***';
}

Testfil

//index.test.ts
import * as myFunctions from '../index';
import * as utils from '../utils';
import * as sinon from 'sinon';
import * as chai from 'chai';

const expect = chai.expect;


describe("Test Suite", () => {

    let functionSpy: sinon.SinonStub;

    beforeEach(() => {
        functionSpy = sinon.stub(utils, 'helperFunction');
        functionSpy.returns(Promise.resolve('Stubbed Function Results!'))
    });

    afterEach(() => {
        functionSpy.restore();
    });

    it('should resolve and call the correct functions.', async () => {
        const returnValue = await myFunctions.mainFunction();

        expect(returnValue).to.equal(1);
        expect(functionSpy.callCount).to.equal(1);
    })
})

Utdata: Tester passerar och jag får: [ 'One', 'Stubbed Function Results!', 'Three' ]

Men om jag försöker stubba nestedHelperFunction fungerar det inte.

// index.test.js
import * as myFunctions from '../index';
import * as utils from '../utils';
import * as sinon from 'sinon';
import * as chai from 'chai';

const expect = chai.expect;


describe("Test Suite", () => {

    let functionSpy: sinon.SinonStub;

    beforeEach(() => {
        functionSpy = sinon.stub(utils, 'nestedHelperFunction'); // Changed
        functionSpy.returns(Promise.resolve('Stubbed Function Results!'))
    });

    afterEach(() => {
        functionSpy.restore();
    });

    it('should resolve and call the correct functions.', async () => {
        const returnValue = await myFunctions.mainFunction();

        expect(returnValue).to.equal(1);
        expect(functionSpy.callCount).to.equal(1);
    })
})

Output Testet misslyckas och jag får oförändrad output: [ 'One', 'helperFunction Result | ***Nested***', 'Three' ]

Varför fungerar det inte när man stubbar nestedHelperFunction men fungerar för helperFunction?

Arbets Exempel

Något som fungerar, men som jag inte förstår varför, är inom utils.ts att skapa helperFunction och nestedHelperFunction som metoder på en klass istället för som 'top level'-funktioner.

// utils.ts

export class Utils {
    static async helperFunction(): Promise<string> {
        const newString = await this.nestedHelperFunction();
        return 'helperFunction Result | ' + newString;
    }

    static async nestedHelperFunction (): Promise<string> {
        return '***Nested Output***';
    }
}

Testfil

// index.test.ts
import {mainFunction} from '../index';
import {Utils} from '../utils';
import sinon from 'sinon';
import * as chai from 'chai';

const expect = chai.expect;


describe("Test Suite", () => {

    let functionSpy: sinon.SinonStub;

    beforeEach(() => {
        functionSpy = sinon.stub(Utils, 'nestedHelperFunction');
        functionSpy.returns(Promise.resolve('Stubbed Function Results!'));
    });

    afterEach(() => {
        functionSpy.restore();
    });

    it('should resolve and call the correct functions.', async () => {
        const returnValue = await mainFunction();

        expect(returnValue).to.equal(1);
        expect(functionSpy.callCount).to.equal(1);
    })
})
// index.ts
import {Utils} from './utils';

export async function mainFunction() {
    console.log('Starting mainFunction...');

    const promiseResults = await Promise.all([
        Promise.resolve('One'),
        Utils.helperFunction(),
        Promise.resolve('Three'),
    ]);

    console.log(promiseResults);
    return 1;
}

Utdata Tester passerar och jag får önskat/förväntat utdata: [ 'One', 'helperFunction Result | Stubbed Function Results!', 'Three' ]

Material jag har läst antyder för mig att det pågår något med att importera es6-moduler eller hur Typescript kompilerar och kan ändra namnen på importerade objekt. I Javascript använde jag Rewire för att ställa in stubbar, några var privata funktioner, men jag hade problem med det i Typescript.

Tack för all hjälp.

Svar #1

Du har rätt att det har att göra med importerna: om du tittar på den genererade koden ser du att inom util.ts är anropet till nestedHelperFunction en direkt referens till den lokala funktionen (dvs nestedHelperFunction(), inte något som util.nestedHelperFunction()).

Din stub ersätter för närvarande bara referensen på modulobjektet (util.nestedHelperFunction), inte den lokala.

Du kommer faktiskt att behöva använda något som Rewire för att stubba ut det.

Du nämnde att du har problem med Rewire och TS. Jag experimenterade nyligen lite med det också, och det verkar som att typningarna för det inte fungerar korrekt (längre?). Huvudproblemet är att dess typningar inte exporterar ett namnrymd med samma namn som den exporterade 'main'-symbolen.

Som en tillfällig lösning (tills de 'officiella' typerna är fixade), kan du spara följande till rewire.d.ts:

declare module "rewire" {
    // Type definitions for rewire 2.5
    // Project: https://github.com/jhnns/rewire
    // Definitions by: Borislav Zhivkov <https://github.com/borislavjivkov>
    //                 Federico Caselli <https://github.com/CaselIT>
    // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
    // TypeScript Version: 2.3

    namespace rewire {
        interface RewiredModule {
            /**
             * Takes all enumerable keys of obj as variable names and sets the values respectively. Returns a function which can be called to revert the change.
             */
            __set__(obj: { [variable: string]: any }): () => void;
            /**
             * Sets the internal variable name to the given value. Returns a function which can be called to revert the change.
             */
            __set__(name: string, value: any): () => void;
            /**
             * Returns the private variable with the given name.
             */
            __get__<T = any>(name: string): T;
            /**
             * Returns a function which - when being called - sets obj, executes the given callback and reverts obj. If callback returns a promise, obj is only reverted after
             * the promise has been resolved or rejected. For your convenience the returned function passes the received promise through.
             */
            __with__(obj: { [variable: string]: any }): (callback: () => any) => any;
        }
    }

    /**
     * Returns a rewired version of the module found at filename. Use rewire() exactly like require().
     */
    function rewire<T = { [key: string]: any }>(filename: string): rewire.RewiredModule & T;
    export = rewire;
}

Sedan (med din omodifierade utils.ts och index.ts) använd följande index.tests.ts:

import rewire = require("rewire");

import * as sinon from 'sinon';
import { expect } from 'chai';

const myFunctions = rewire<typeof import("../index")>("../index");
const utils = rewire<typeof import("../utils")>("../utils");

describe("Test Suite", () => {

    let functionSpy: sinon.SinonStub;
    let restoreRewires: Array<() => void>;

    beforeEach(() => {
        restoreRewires = [];
        functionSpy = sinon.stub();
        functionSpy.returns(Promise.resolve('Stubbed Function Results!'))
        restoreRewires.push(
            utils.__set__("nestedHelperFunction", functionSpy),
            myFunctions.__set__("utils", utils),
        );
    });

    afterEach(() => {
        restoreRewires.forEach(restore => restore());
    });

    it('should resolve and call the correct functions.', async () => {
        const returnValue = await myFunctions.mainFunction();

        expect(returnValue).to.equal(1);
        expect(functionSpy.callCount).to.equal(1);
    });
});

Detta skriver ut exakt det du ville ha:

  Test Suite
Starting mainFunction...
[ 'One',
  'helperFunction Result | Stubbed Function Results!',
  'Three' ]
    ✓ should resolve and call the correct functions.


  1 passing (31ms)