How to mock dependencies when testing GAS with CLASP
Asked Answered
F

1

9

Background

I recently learned about CLASP and became excited about the possibility of using TDD to edit my Google Apps Scripts (GAS) locally.

NOTE: there might be a way to write tests using the existing GAS editor, but I'd prefer to use a modern editor if at all possible

clasp works great, but I cannot figure out how to mock dependencies for unit tests (primarily via jest, though I'm happy to use any tool that works)

  • I got farthest by using the gas-local package, and was able to mock a single dependency within a test
    • However I could not find a way to mock multiple dependencies in a single test/call, and so I created this issue

Challenge

Despite installing @types/google-apps-script, I am unclear on how to "require" or "import" Google Apps Script modules whether using ES5 or ES2015 syntax, respectively--see below for an illustration of this.

Related StackOverflow Post

Although there is a similar SO question on unit testing here, most of the content/comments appear to be from the pre-clasp era, and I was unable to arrive at a solution while following up the remaining leads. (Granted, it's very possible my untrained eye missed something!).

Attempts

Using gas-local

As I mentioned above, I created an issue (see link above) after trying to mock multiple dependencies while using gas-local. My configuration was similar to the jest.mock test I describe below, though it's worth noting the following differences:

  • I used ES5 syntax for the gas-local tests
  • My package configuration was probably slightly different

Using jest.mock

LedgerScripts.test.js

import { getSummaryHTML } from "./LedgerScripts.js";
import { SpreadsheetApp } from '../node_modules/@types/google-apps-script/google-apps-script.spreadsheet';

test('test a thing', () => {
    jest.mock('SpreadSheetApp', () => {
        return jest.fn().mockImplementation(() => { // Works and lets you check for constructor calls
          return { getActiveSpreadsheet: () => {} };
        });
      });
    SpreadsheetApp.mockResolvedValue('TestSpreadSheetName');

    const result = getSummaryHTML;
    expect(result).toBeInstanceOf(String);
});

LedgerScripts.js

//Generates the summary of transactions for embedding in email
function getSummaryHTML(){  
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var dashboard = ss.getSheetByName("Dashboard");

  // Do other stuff  
  return "<p>some HTML would go here</p>"
}

export default getSummaryHTML;

Result (after running jest command)

Cannot find module '../node_modules/@types/google-apps-script/google-apps-script.spreadsheet' from 'src/LedgerScripts.test.js'

      1 | import { getSummaryHTML } from "./LedgerScripts.js";
    > 2 | import { SpreadsheetApp } from '../node_modules/@types/google-apps-script/google-apps-script.spreadsheet';
        | ^
      3 | 
      4 | test('test a thing', () => {
      5 |     jest.mock('SpreadSheetApp', () => {

      at Resolver.resolveModule (node_modules/jest-resolve/build/index.js:307:11)
      at Object.<anonymous> (src/LedgerScripts.test.js:2:1)

For reference, if I go to the google-apps-script.spreadsheet.d.ts file that has the types I want, I see the following declarations at the top of the file...

declare namespace GoogleAppsScript {
  namespace Spreadsheet {

...and this one at the bottom of the file:

declare var SpreadsheetApp: GoogleAppsScript.Spreadsheet.SpreadsheetApp;

So maybe I am just importing SpreadsheetApp incorrectly?

Other files

jest.config.js

module.exports = {
    
    clearMocks: true,
    moduleFileExtensions: [
      "js",
      "json",
      "jsx",
      "ts",
      "tsx",
      "node"
    ],
    testEnvironment: "node",
  };

babel.config.js

module.exports = {
  presets: ["@babel/preset-env"],
};

package.json

{
  "name": "ledger-scripts",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "jest"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@babel/core": "^7.11.1",
    "@babel/preset-env": "^7.11.0",
    "@types/google-apps-script": "^1.0.14",
    "@types/node": "^14.0.27",
    "babel-jest": "^26.3.0",
    "commonjs": "0.0.1",
    "eslint": "^7.6.0",
    "eslint-plugin-jest": "^23.20.0",
    "gas-local": "^1.3.1",
    "requirejs": "^2.3.6"
  },
  "devDependencies": {
    "@types/jasmine": "^3.5.12",
    "@types/jest": "^26.0.9",
    "jest": "^26.3.0"
  }
}
Fritter answered 15/8, 2020 at 17:8 Comment(2)
Checkout github.com/artofthesmart/qunitgs2Nicodemus
TheMaster: That's pretty cool! As long as approaches like the one that one @dwmorrin suggested work then I am probably fine getting my results in the console. But I will keep gunitgs2 in mind if I want to view results in a browser :)Fritter
L
7

Note: the scope of your question is broad and may require clarification.

clasp works great, but I cannot figure out how to mock dependencies for unit tests (primarily via jest, though I'm happy to use any tool that works)

You don't need Jest or any particular testing framework to mock the global Apps Script objects.

// LedgerScripts.test.js
import getSummaryHTML from "./LedgerScripts.js";

global.SpreadsheetApp = {
  getActiveSpreadsheet: () => ({
    getSheetByName: () => ({}),
  }),
};

console.log(typeof getSummaryHTML() === "string");
$ node LedgerScripts.test.js
true

So maybe I am just importing SpreadsheetApp incorrectly?

Yes, it is incorrect to import .d.ts into Jest. Jest doesn't need the TypeScript file for SpreadsheetApp. You can omit it. You only need to slightly modify the above example for Jest.

// LedgerScripts.test.js - Jest version
import getSummaryHTML from "./LedgerScripts";

global.SpreadsheetApp = {
  getActiveSpreadsheet: () => ({
    getSheetByName: () => ({}),
  }),
};

test("summary returns a string", () => {
  expect(typeof getSummaryHTML()).toBe("string");
});

Despite installing @types/google-apps-script, I am unclear on how to "require" or "import" Google Apps Script modules whether using ES5 or ES2015 syntax

@types/google-apps-script does not contain modules and you do not import them. These are TypeScript declaration files. Your editor, if it supports TypeScript, will read those files in the background and suddenly you'll have the ability to get autocomplete, even in plain JavaScript files.

Additional comments

  • Here you check that a function returns a string, perhaps just to make your example very simple. However, it must be stressed that such testing is better left to TypeScript.
  • Since you returned an HTML string, I feel obligated to point out the excellent HTML Service and templating abilities of Apps Script.
  • Unit testing or integration testing? You mention unit testing, but relying upon globals is generally a sign you might not be unit testing. Consider refactoring your functions so they receive objects as input rather than calling them from the global scope.
  • Module syntax: if you use export default foo, you then import without curly braces: import foo from "foo.js" but if you use export function foo() { then you use the curly braces: import { foo } from "foo.js"
Listen answered 15/8, 2020 at 20:31 Comment(6)
Wow. Thanks for the detailed response @dwmorrin! I am honestly stunned at the relative simplicity (and effectiveness) of the changes you suggested. I was able to run my test successfully! Re: your points:(sorry for markdown fail) - Why is it necessary to nest the getSheetByName dependency within the mock (?) of getActiveSpreadsheet? - re: HTML: at point I started using SheetConverter to help me display sheet content within emails - unit vs. integration testing: I would love to use a more local scopeFritter
Inlining the ss variable, you have SpreadsheetApp.getActiveSpreadsheet().getSheetByName(). The dot syntax requires the left hand expression to be an object, and the parentheses require the left hand expression to be a function, thus .getSheetByName() must be both a function and a property of the object getActiveSpreadsheet() returns. These are all just plain JavaScript rules, not related to GAS or Jest. A deep dive into JavaScript will clarify those questions for you.Listen
Is there a way to create global.SpreadsheetApp = { getActiveSpreadsheet: () => ({ getSheetByName: () => ({}), }), }; automatically from original js files?Nicodemus
@Nicodemus - created from the .js files to test? My first concern is that you are diminishing the value of the test at that point. It's imaginable that you could write a program to parse the .d.ts files for GAS and produce some dummy code for Jest, but again the value of that autogeneration is questionable. If you manually wrote them, you could keep adding them to a .js file and import that file into your test files as needed. Manually writing out the expected global API required for the function could be seen as valuable documentation in itself, especially if the API should change.Listen
@Listen A initial template test file would be able to save time. You can then modify the template as you see fit. EDIT: found thisNicodemus
@Listen - I think I see what you're saying. Most of my experience with JavaScript has been via TypeScript, so I'll probably try converting my GAS and tests to use TypeScript when possible. @Nicodemus - you asked: Is there a way to create mocks (?) automatically from original js files?. I believe that is what gas-local tries to accomplish. But even if I were able to get it fully working, I like being able to manually define a type for each mock. So I'll have to try ts-auto-mock. Thx!Fritter

© 2022 - 2024 — McMap. All rights reserved.