forcedotcom / LightningTestingService

Apache License 2.0
122 stars 35 forks source link

How to test behavior dependent on afterScriptsLoaded #56

Closed kvanderwyk closed 4 years ago

kvanderwyk commented 6 years ago

Apologies in advance for the wordy post but I wanted to provide enough context. I've posted this as a question on StackExchange but haven't gotten any response there, so forgive me for repeating it here, but I'm having trouble finding enough examples of LTS use cases to help me figure this out. This is also somewhat related to Keith's question in #54 ...

I’m looking for help/examples for setting up tests of a component which calls a load() method via afterScriptsLoaded, then renders data based on the result of the load (which calls a server-side controller) — something like this:

Component

<ltng:require scripts="{!join(',',$Resource.Constants,$Resource.Utils)}" afterScriptsLoaded="{!c.load}"/>

<aura:attribute name="currencyOptions" type="OppActionOverrideController.CurrencyOption[]" description="List of currency options with user's preferred currency set to default"/>
<aura:attribute name="someOtherThing" type="String" description="This is just an example of something else to render"/>

<aura:attribute name="showCurrencyField" type="Boolean" default="false" description="Value set in the load() callback"/>
<aura:attribute name="showSomeOtherField" type="Boolean" default="false" description="Value set in the load() callback"/>

<div class="slds-grid slds-wrap">
    ....
    <aura:if isTrue="{!v.showCurrencyField}">
    <div class="slds-form-element">
        <ui:inputSelect aura:id="selectedCurrency" label="Currency" required="true">
            <aura:iteration items="{!v.currencyOptions}" var="curr">
                <ui:inputSelectOption text="{!curr.isoCode}" label="{!curr.isoCode}" value="{!curr.isDefault}"/>
            </aura:iteration>
        </ui:inputSelect>
    </div>
    </aura:if>

    <aura:if isTrue="{!v.showSomeOtherField}">
        <div aura:id="someOtherField">foobar</div>
    </aura:if>
    ....
</div>

Controller

load: function (component, event, handler) {
    var action = component.get("c.getComponentData");
    action.setCallback(this, function(response) {
        // ....do some validation, if response is successful....
        // set up currency options
        component.set('v.showCurrencyField', true);
        component.set('v.currencyOptions', currencyOptions);

        // set other things
        component.set('v.showSomeOtherField', true);
        component.set('v.someOtherThing', 'hello');
    });
    $A.enqueueAction(action);
}

Test suite

afterEach(function() {
    $T.clearRenderedTestComponents();
});
describe("Rendering the component", function() {
    var getComponentDataPayload = {
        currencyOptions: [
            {isoCode: "USD", isDefault: true},
            {isoCode: "EUR", isDefault: false},
            {isoCode: "GBP", isDefault: false},
        ]
    };
    var renderedComponent = null;

    beforeEach(function(done) {
        renderedComponent = null;

        $T.createComponent("c:MyComponent", {}, true)
        .then(function(component) {
            // I assign the component to a separate var so that it can be referenced in specs below, allows me to use a beforeEach instead of having this code in every spec
            renderedComponent = component;

            spyOn($A, "enqueueAction").and.callFake(function(action) {
                var callback = action.getCallback("SUCCESS");
                // The utility function here just takes the payload data and wraps it with the proper getState() and getReturnValue(), etc.
                callback.fn.apply(callback.s, [TestUtils.getMockedServerActionResult(true, "", getComponentDataPayload)]);
                done();
            });
        }).catch(funtion(e) {
            done.fail(e);
        });
    });

    it("renders the currency options", function(done) {
        expect(renderedComponent.find("selectedCurrency")).toBeDefined();
        done();
    });

    it("renders some other field", function(done) {
        expect(renderedComponent.find("someOtherField")).toBeDefined();
        done();
    });
});

The specific problem I’m struggling with is where to place the spyOn (either inside the createComponent callback as above, or before it, or elsewhere) and also where to call done() in the beforeEach. The way the example is defined above, the first spec passes. But when the second spec runs, the scripts are already loaded, so that load() call happens immediately, before the createComponent callback happens (and so the spy is not registered and the done() function inside it is never called). I’ve tried moving the spy outside of the createComponent callback and calling done() in different places, but each results in a condition where either the renderedComponent var is not defined by the time done() is called (and thus the specs fail) or the done() method is never called due to the order that things execute the second time through and I get a timeout.

Is there a better way to structure this spec so that the order of the load call in relation to when the component is done rendering can be more predictable? I prefer to keep my it blocks simple and re-render the component each time, and re-rendering the component with different data to test conditional display is required for this test.

UPDATE
After experimenting a bit more I've tried modifying the structure of the beforeEach to this:

beforeEach(function(done) {
    renderedComponent = null;

    spyOn($A, "enqueueAction").and.callFake(function(action) {
        var callback = action.getCallback("SUCCESS");
        // The utility function here just takes the payload data and wraps it with the proper getState() and getReturnValue(), etc.
        callback.fn.apply(callback.s, [TestUtils.getMockedServerActionResult(true, "", getComponentDataPayload)]);
    });

    $T.createComponent("c:MyComponent", {}, true)
    .then(function(component) {
        // I assign the component to a separate var so that it can be referenced in specs below, allows me to use a beforeEach instead of having this code in every spec
        renderedComponent = component;

        return $T.waitFor(function() {
            return renderedComponent.find("selectedCurrency") !== undefined;
        });
    }).then(function() {
        done();
    }).catch(function(e) {
        done.fail(e);
    });
});

To summarize the difference from the above version, this moves registering the spy out of the createComponent callback, which ensures it always happens. Then I am using a bit of a hack with waitFor to make sure the pieces of the component under test have indeed been rendered before testing them. This seems to result in a consistent order of operations, but now I am seeing timeouts (using the LTS-defined 3s) with the $T.createComponent, but only when running three or more specs. The first two specs run quickly and without a problem.

I'm hoping that I'm missing something obvious with this setup, does anyone have any ideas or examples of doing something similar?

esalman-sfdc commented 6 years ago

I think your updated approach is on the right track. There is some discussion here which may be relevant to you.

May be try using action.getName() to ensure you are mocking the correct action. Do you know if the timeout failure happens in waitFor or somewhere else? If its possible for you to share an unmanaged package or a deployable sfdx project with repro, I can have a closer look.

kvanderwyk commented 6 years ago

Hi @esalman-sfdc, thanks for your response. The timeout happens when rendering the component (via $T.createComponent) for the third test in the suite. This happens regardless of what in particular is being tested -- I can have 15 tests and have tried randomly disabling/enabling them; if I run 2 at a time it works fine, 3 or more and I get the timeout from $T.createComponent. The spec times out before getting to the waitFor or anything else.

I will try debugging with action.getName() as you suggest just to rule that out, and will see if I can repro this in an SFDX environment that doesn't expose our code.

maniax89 commented 6 years ago

@kvanderwyk I don't know if it helps you, but using the mocha version of the script, I make a call to update the timeout before each test:

const { expect } = chai;
const sandbox = sinon.sandbox.create();

describe('MyComponent', function test() {
  this._timeout = 5000;
  this._retries = 3;

  afterEach(() => {
    $T.clearRenderedTestComponents();
    sandbox.restore();
  });
});