DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 963,864 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
remi bourgarel
remi bourgarel

Posted on • Originally published at remibou.github.io on

Test Your JSInterop Code With Cypress

Test your JSInterop code with cypress.io

Cypress.io is a game changer in the world of web E2E test. So far it was dominated by WebDriver based framework but it has the following advantages :

  • It’s easy to setup
  • It’s easy to integrate into a CI pipeline
  • The API are fine (I still don’t like the assertion methods)
  • The debug information it provides are golden and makes your tests easy to fix
  • There is a lot of methods for making your tests less flaky (you do’nt have to add random wait every 2 lines)

The only disadvantage being the maturity of the tool so there is some missing pieces like built-in file upload or spying of fetch request but the community is quite large and there is always a 3rd party script/lib for fixing what is missing.

How to test for a method call

In cypress there is multiple methods for spying or stubbing the navigator methods for instance :


context('window.console', () => {
    before(() => {
        cy.visit('/');
    });

    it('Check console methods called', () => {
        cy.window()
            .then((w) => {
                cy.spy(w.console, "log");               
                cy.get("#btn-console-do-test").click()
                    .then(() => {
                        expect(w.console.log).be.called.calledTwice;
                    });
            });
    });
}
);

This test clicks on a button and then expect the console.log to be called twice.

The problem with JSInterop

This would work very well in a pure js application. If the console.log method calls are done with JSInterop like in a Blazor WASM app :

await jsRuntime.InvokeVoidAsync("console.log","test");
//or
Console.WriteLine("test");

This would fail with this error :

blazor.webassembly.js:1 WASM: ο»ΏUnhandled exception rendering component:
blazor.webassembly.js:1 WASM: Microsoft.JSInterop.JSException: The value 'window.console.log' is not a function.
blazor.webassembly.js:1 WASM: Error: The value 'window.console.log' is not a function.
blazor.webassembly.js:1 WASM: at p (http://localhost:5000/_framework/blazor.webassembly.js:1:9063)
blazor.webassembly.js:1 WASM: at http://localhost:5000/_framework/blazor.webassembly.js:1:9605
blazor.webassembly.js:1 WASM: at new Promise (<anonymous>)
blazor.webassembly.js:1 WASM: at Object.beginInvokeJSFromDotNet (http://localhost:5000/_framework/blazor.webassembly.js:1:9579)
blazor.webassembly.js:1 WASM: at _mono_wasm_invoke_js_marshalled (http://localhost:5000/_framework/wasm/mono.js:1:165611)
blazor.webassembly.js:1 WASM: at wasm-function[6221]:0x11936a
blazor.webassembly.js:1 WASM: at wasm-function[1431]:0x402ee
blazor.webassembly.js:1 WASM: at wasm-function[636]:0x147cf
blazor.webassembly.js:1 WASM: at wasm-function[4996]:0xeb135
blazor.webassembly.js:1 WASM: at wasm-function[3247]:0xa0666
blazor.webassembly.js:1 WASM: at System.Threading.Tasks.ValueTask`1[TResult].get_Result () <0x20a9640 + 0x0002c> in <5745b1bd6f4246d7aee8c81307e6355a>:0 
blazor.webassembly.js:1 WASM: at Microsoft.JSInterop.JSRuntimeExtensions.InvokeVoidAsync (Microsoft.JSInterop.IJSRuntime jsRuntime, System.String identifier, System.Object[] args) <0x2081800 + 0x000e4> in <3eedf0ca90ca4e72bf6870618ca98c7c>:0 

This is due to this code in Microsoft.JSInterop you can find in this file :

function findJSFunction(identifier: string): Function {
    if (cachedJSFunctions.hasOwnProperty(identifier)) {
        return cachedJSFunctions[identifier];
    }

    let result: any = window;
    let resultIdentifier = 'window';
    let lastSegmentValue: any;
    identifier.split('.').forEach(segment => {
        if (segment in result) {
        lastSegmentValue = result;
        result = result[segment];
        resultIdentifier += '.' + segment;
        } else {
        throw new Error(`Could not find '${segment}' in '${resultIdentifier}'.`);
        }
    });

    if (result instanceof Function) {
        result = result.bind(lastSegmentValue);
        cachedJSFunctions[identifier] = result;
        return result;
    } else {
        throw new Error(`The value '${resultIdentifier}' is not a function.`);
    }
}

You see the problem ? No ? Well it’s not obvious :

  • In JS types are defined by window. eg : if you have an iframe then the type β€œFunction” inside the iframe is not the same as the β€œFunction” type in the parent.
  • Cypress uses iframes for running the tests (that’s why you are not limited like in WebDriver)
  • when you call cy.spy, it changes the definition of console.log, so its type becomes a β€œFunction” in the context of the runner iframe, not the app.

How do I fix this ?

Fortunately Javascript allows us to do very stupid things, like changing an object prototype on the fly ! After the fix, my Cypress code test looks this :


context('window.console', () => {
    before(() => {
        cy.visit('/console');
    });

    it('Check console methods called', () => {
        cy.window()
            .then((w) => {
                cy.spy(w.console, "log");
                w.console.log. __proto__ = w.Function;
                cy.get("#btn-console-do-test").click()
                    .then(() => {
                        expect(w.console.log).be.called.calledThrice;
                    });
            });
    });
}
);

Notice the line after the spy (which is synchronous) which changes console.log prototype, this makes the β€œinstanceof Function” condition pass and my test run successfully.

I created the following Cypress command to reduce code duplication


Cypress.Commands.add('spyFix', (object, method, window) => {
    cy.spy(object, method);
    object[method]. __proto__ = window.Function;
});

context('window.console', () => {
    before(() => {
        cy.visit('/console');
    });

    it('Check console methods called', () => {
        cy.window()
            .then((w) => {
                cy.spyFix(w.console, "log", w);
                cy.get("#btn-console-do-test").click()
                    .then(() => {
                        expect(w.console.log).be.called.calledThrice;
                    });
            });
    });
}
);

Conclusion

I’m happy I found an easy way to fix this, there is other ways with β€œnew win.Function” or by overriding some method in window.DotNet but they require more line of code and are much more complicated.

Top comments (0)

DEV has this feature:

Settings

Go to your customization settings to nudge your home feed to show content more relevant to your developer experience level. πŸ›