Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chore: Add jest unit test for salesforcedx-utils package #4470

Merged
merged 11 commits into from
Oct 14, 2022

Conversation

gbockus-sf
Copy link
Contributor

@gbockus-sf gbockus-sf commented Oct 1, 2022

What does this PR do?

Create the first set of jest unit tests that are using purely jest for unit testing.

What issues does this PR fix or reference?

@W-11309496@

Functionality Before

  • No unit tests for salesforcedx-utils.

Functionality After

  • Unit test coverage for utils package.
  • Example of how to write tests using jest expects instead of mocha/chai

Screen Shot 2022-10-01 at 1 05 05 AM

@gbockus-sf gbockus-sf requested a review from a team as a code owner October 1, 2022 06:09
@@ -2,7 +2,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: [ "**/unit/**/?(*.)+(spec|test).[t]s?(x)" ],
testMatch: [ "**/(unit|jest)/**/?(*.)+(spec|test).[t]s?(x)" ],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unit tests will now be valid under a unit or jest directory.
The intention is the jest directory is the new location for jest true unit tests.

{
testMatch: [ "**/unit/**/?(*.)+(spec|test).[t]s?(x)" ],
}
baseConfig
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean up unnecessary override.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏽
Also, TS is complaining about top line of this file for me:
2022-10-12_13-57-55

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool..yeah it's a js file so can be ignored. I can add a ignore comment I think

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just an FYI this was only showing u in the utils projects which using eslint vs tslint. So I only updated that config file

export const NO_STDOUT_ERROR = 'No stdout found for childProcess';
export const NO_STDERR_ERROR = 'No stderr found for childProcess';
export const CANCELATION_INTERVAL = 1000;
export const KILL_CODE = 'SIGKILL';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exported constants so they can be verified in unit tests.

@@ -83,79 +74,3 @@ export class CliCommandExecutor {
);
}
}

export class CompositeCliCommandExecutor {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put class in it's own file for cleaner organization and import.

import { CommandExecution } from '../types/commandExecution';
import { Command } from './command';

export class CompositeCliCommandExecution implements CommandExecution {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing new here just moved to a separate file.

import { Command } from './command';
import { CompositeCliCommandExecution } from './compositeCliCommandExecution';

export class CompositeCliCommandExecutor {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same story: 1 class exported to its own file.

@@ -10,7 +10,8 @@ import { CommandOutput } from './commandOutput';
import { SfdxCommandBuilder } from './sfdxCommandBuilder';
/**
* @deprecated
* NOTE: This code is deprecated in favor of using ConfigUtil.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why I deleted this NOTE honestly. Probably need to visit if this command is necessary.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to put this back? Or add to the same line as deprecated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok..yeah I'll put it back. This is only used in the debuggers currently that can't access configUtils in the vscode-utils package FYI.

@@ -1,7 +1,8 @@
import { CommandBuilder } from './commandBuilder';

export const SFDX_COMMAND = 'sfdx';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should probably put this in constants.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good idea!

@@ -27,59 +19,3 @@ export class Localization implements LocalizationProvider {
return this.delegate.localize(label, ...args);
}
}

export type MessageBundle = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same story as above. Organize by putting exports in their own files.

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our first snapshot. These are generated by jest the first time a shouldMatchSnapshot type call is made. Then after the value found here is compare on the matchSnapshot call in future tests. If it doesn't match the assert will fail. If the value has change you can updated it with a flag passed to the test (included in the failure output).

Thing to take away is snapshot should be treated as code and reviewed closely on change

} from '../../../src/cli/cliCommandExecution';
import { Observable } from 'rxjs/Observable';
import * as kill from 'tree-kill';
jest.mock('tree-kill');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mocks the module in memory in jest. So when it's used in the code under test the underlying functions are just mocks.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this will become a standard practice for us to use across jest tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct. If you need to mock and entire module for a test this is the easiest way to do it.

import * as kill from 'tree-kill';
jest.mock('tree-kill');

const treeKillMocked = jest.mocked(kill);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how you correctly type the mocked function for use in the test while keeping typescript happy.

} as any);
intervalSpy = jest.spyOn(Observable, 'interval').mockReturnValue({
subscribe: subscribeSpy
} as any);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is testing a class that uses rxjs. It leads to some pretty strange tests.

});

afterEach(() => {
jest.resetAllMocks();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've worked on projects previously where we set this in the global after for all tests. I'm not sure if that's the right move or not but we could put that in place.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be cool. Is there ever a situation where one would want to not have this happen after a test?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I wouldn't think so. It wouldn't be goodto have one test depending on setup from another so I think we could just reset all Mocks by default.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep sounds good to me!

testCommand,
testChildProcess
);
expect(cliCommandExecution).toBeInstanceOf(CliCommandExecution);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that when using jest it automatically injects jest and it's components like expect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK so that means they don't have to be explicitly imported?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

solid

'data'
]);

expect(subscribeSpy).toHaveBeenCalledTimes(2);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a ton of good options for jest expects: https://jestjs.io/docs/expect

testChildProcess,
testCancelationToken
);
}).toThrowError(NO_PID_ERROR);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the syntax for verifying that a function call throws an error. It's a little weird that you have to wrap the call in an arrow function (I almost always forget).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be good to add in tests.md

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better yet, add an instruction to create a snippet... or maybe we could ship some 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I'll follow on this PR with an update to tests.md

expect(subscribeSpy).toHaveBeenCalledTimes(3);
const timoutHandler = subscribeSpy.mock.calls[2][0];
const timeoutPromise = timoutHandler();
expect(treeKillMocked).toHaveBeenCalledTimes(1);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note how we can use the treeKillMocked here to verify that an dependent module was called.

killCallback();
} else {
fail('Should have had a kill callback function.');
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The treekill function is odd in that it takes a callback as a parameter. To test the callback code I had to call it here.

import { CliCommandExecution } from '../../../src/cli/cliCommandExecution';
import { GlobalCliEnvironment } from '../../../src/cli/globalCliEnvironment';
jest.mock('cross-spawn');
jest.mock('../../../src/cli/cliCommandExecution');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note this is a mock for a third party module vs a local module.


beforeEach(() => {
// Add a global value to the GCE option for processing during creation.
GlobalCliEnvironment.environmentVariables.set(globalKey, globalValue);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an exported Map so I decided to just set a value verse mocking the Map functionality.

fakeCommand,
fakeChildProcess,
undefined
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note we're not calling the underlying executor here but just verifying that it should have been called.

let getCmdResultSpy: jest.SpyInstance;
beforeEach(() => {
executeSpy = jest
.spyOn(CliCommandExecutor.prototype, 'execute')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jest.spyOn =~ sandbox.stub

.mockReturnValue(fakeExecution as any);
getCmdResultSpy = jest
.spyOn(CommandOutput.prototype, 'getCmdResult')
.mockResolvedValue(fakeResult);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: mockResolved value returns a promise wrapped results. mockReturnValue is without promises.

getCmdResultSpy.mockResolvedValue(partialJson);
const forceConfigGet = new ForceConfigGet();
// Unexpected token error is thrown b/c json can not be parsed.
expect(forceConfigGet.getConfig(fakePath)).rejects.toThrowError(/Unexpected token/);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the syntax for checking if an error is thrown by making a call.

expect(commandBuilderInst).toBeInstanceOf(CommandBuilder);
const testCommand = commandBuilderInst.build();
expect(testCommand).toBeInstanceOf(Command);
expect(testCommand.command).toBe(testCommandStr);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I sort of figured out as I went that .toBe and .toEqual are pretty much the same but toBe won't work sometimes which led me to search for the diff where I found https://dev.to/thejaredwilcurt/why-you-should-never-use-tobe-in-jest-48ca#:~:text=toEqual%20and%20the%20.,equality%20fails%20on%20non%2Dprimatives.

So bottom line we should probably just default to toEqual
I'll update these tests to get rid of toBe

},
],
}
`;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

much more interesting snapshots here. Note here it is useful b/c I don't have to check all the properties individually or in a constructed object in the call like headers, data, etc.

},
"timeout": 20000,
"type": "POST",
"url": "https://where.is/the/instance/https://this.is.a.test",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll verify why this looks weird.

const result = await requestServiceInst.execute(testCommand);
expect(result).toBe(fakeResponse.responseText);
expect(sendRequestMock).toHaveBeenCalled();
expect(sendRequestMock).toMatchSnapshot();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should probably update this to snapshot on the args and not the entire mock

.withLogName('testLogName');

const command = new Command(commandBuilderInst);
expect(command.toString()).toMatchSnapshot();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example snapshot expect

Copy link
Contributor

@randi274 randi274 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly questions and spelling; thanks for getting all of this ramped up for us!

@@ -7,6 +7,10 @@

import { Command } from './command';

export const JSON_FLAG = '--json';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this centralized place for these. Our libraries could do with the same treatment, if we were investing there.

@@ -10,7 +10,8 @@ import { CommandOutput } from './commandOutput';
import { SfdxCommandBuilder } from './sfdxCommandBuilder';
/**
* @deprecated
* NOTE: This code is deprecated in favor of using ConfigUtil.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to put this back? Or add to the same line as deprecated

@@ -1,7 +1,8 @@
import { CommandBuilder } from './commandBuilder';

export const SFDX_COMMAND = 'sfdx';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good idea!

if (args.length >= 1) {
const expectedNumArgs = possibleLabel.split('%s').length - 1;
if (args.length !== expectedNumArgs) {
// just log it, we might want to hide some in some languges on purpose
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: typo on languages

}
}

return format(possibleLabel, ...labelArgs); // TODO: verify this works
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

..does it? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does :)

expect(command.logName).toBeUndefined();
});

it('Should be abled to pass a fully built builder.', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: typo on abled

expect(command.toString()).toEqual(testDescription);
});

it('Should construct string if no descirption provided.', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: typo on description

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, are we building a command too? Test name is a little unclear

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this is verifying that the toString method works as expected.

let commandOutput: CommandOutput;
let result: Promise<string>;

// It's a pain to unit test Obserables. Open to exploring other options.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Obserables -> Observables

@@ -0,0 +1,102 @@
import { CommandOutput } from '../../../src';

describe('CommandOuput Unit Tests.', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: CommandOuput -> CommandOutput

expect(label).toEqual(expected);
});

it('Should only partially populate if too few args are passed.', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't it also log an error, or was that elsewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't make the rules...just tested that it works they way it was coded :)

@@ -32,12 +34,12 @@ export class RequestService {
private _proxyAuthorization!: string;
private _connectionTimeoutMs: number = DEFAULT_CONNECTION_TIMEOUT_MS;

public getEnvVars(): any {
public getEnvVars(): NodeJS.ProcessEnv {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏽

it('Should have special handling for the json flag.', () => {
const testCommand = commandBuilderInst.withArg(JSON_FLAG);
expect(testCommand.args).toEqual(
expect.arrayContaining([JSON_FLAG, LOG_LEVEL_FLAG, FATAL])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const labelKey = 'notAKey';
const label = message.localize(labelKey);
const expected = `${MISSING_LABEL_MSG} ${labelKey}`;
expect(label).toEqual(expected);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like these tests execute a console.warn that ends up being reported when npm run test:unit is run. Is that correct? Could something be stubbed so that this console.warn does not get output when the tests are run?
2022-10-12_17-04-33

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is correct...I'll see what I can do

Copy link
Contributor

@klewis-sfdc klewis-sfdc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great to me, Gordon. Just the one question about the console.warn thing. Thanks for bringing jest into the fold!

@gbockus-sf gbockus-sf merged commit b12b2f7 into develop Oct 14, 2022
@gbockus-sf gbockus-sf deleted the gbockus/jest-tests-for-utils-1 branch October 14, 2022 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants