One Small Change
Posted: April 26, 2021

One Small Change

Table of Contents:

  • In The Beginning
  • Our LWC Jest Tests
  • Using the LWC renderedCallback Lifecycle Hooks
  • Debugging LWC Jest Tests
  • Making The Tests Pass
  • One Small Change Wrap-up

My manager and I met up the other morning for a paired programming session. We had two possible things to work on and decided to embrace Kent Beck’s “start small, or not at all,” by focusing on a seemingly innocuous task — adding the disabled attribute to a lightning-button in one of our Lightning Web Components (LWC). Even with strict adherence to TDD, this is a task that should take only a few minutes; it’s not a complicated component, and the change is extremely straightforward.

Would it surprise you to know that over the next hour and a half we tested our sanity trying to debug this seemingly simple problem? I’d like to walk through the problems we experienced, in the hopes that it will prove useful both as a lesson learned, and as an encouragement in adopting Jest unit tests for LWC.

In The Beginning

This is a dumbed down version of the component we started with:

<lightning-button
  data-id="viewObjectButton"
  label="{SOME_LABEL_ATTRIBUTE}"
  variant="brand"
  onclick="{navToObject}"
></lightning-button>

This is about as simple as it gets. To add the disabled attribute to the component, all we needed to do was check whether or not a value returned from our Apex controller existed or not — I’ll start by showing just how simple the markdown change is:

<lightning-button
data-id="viewObjectButton"
label={SOME_LABEL_ATTRIBUTE}
variant="brand"
onclick={navToObject}
+disabled={isNavToObjectDisabled}
></lightning-button>

And then the JavaScript controller:

// ... other imports above
import getCustomObject from "@salesforce/apex/OurExampleComponent.getCustomObject";

export default class OurExampleComponent extends NavigationMixin(
  LightningElement
) {
  SOME_LABEL_ATTRIBUTE = "View Custom Object"; // better than lorum ipsum ?

  isNavToObjectDisabled = false;

  async navToObject() {
    // original version of the handler, before "disabled" attribute was added
    const customObject = await getCustomObject();
    if (customObject && customObject.Id) {
      this[NavigationMixin.Navigate]({
        type: "standard__recordPage",
        attributes: {
          objectApiName: "OurCustomObjectName",
          actionName: "view",
          recordId: customObject.Id,
        },
      });
    }
    // some error handling for the negative case
    // which we can remove as part of this story
    // since it's enough for the button to be unclickable
  }
}

So — imperative Apex to load the record Id we were looking to navigate to, and (unshown) a toast message to the user if the record could not be loaded. Swapping out the toast message for disabling the button really only has one additional complication — we’ll need to have the record pre-loaded prior to the button being pressed in order to determine whether or not to set our new isNavToObjectDisabled boolean.

Our LWC Jest Tests

We’re a TDD team. The goal is to never touch production code (though I’ve provided a preview of that with the addition of the isNavToObjectDisabled property) without first writing a failing test. This is a relatively simple test, in the scheme of things — and one that, I think, showcases just how powerful writing unit tests for LWC can be:

import { createElement } from "lwc";
import OurExampleComponent from "c/ourExampleComponent";
import { getNavigateCalledWith } from "lightning/navigation";
import getCustomObject from "@salesforce/apex/OurExampleComponentController.getCustomObject";

jest.mock(
  "@salesforce/apex/OurExampleComponentController.getCustomObject",
  () => ({ default: jest.fn() }),
  {
    virtual: true,
  }
);

function setupTest() {
  const element = createElement("c-our-example-component", {
    is: OurExampleComponent,
  });
  document.body.appendChild(element);

  return { element };
}

describe("Our example component tests", () => {
  it("disables navigation to custom object if there is no object id", () => {
    getCustomObject.mockResolvedValue(null);

    const { element } = setupTest();

    return Promise.resolve().then(() => {
      // the use of data-* attributes (refer to the HTML markup above)
      // should send a signal: these attributes either need to be used in events
      // to communicate details about the element in question, or they're
      // going to be used as selectors in tests
      const button = element.shadowRoot.querySelector(
        '[data-id="viewObjectButton"]'
      );
      expect(button.disabled).toBeTruthy();
    });
  });
});

By using jest.mock in combination with our declared Apex controller method, we’re able to get access to the jest.fn()’s mockResolvedValue() function — anything you pass into that function is then returned in place of what would be returned in production-level code by Apex itself. Again, the test itself is laughably simple — all we need to do to get a test failure going is mount the component, saying that the returned Apex value will be null — that should be enough to have the button be disabled. The test fails, as we haven’t implemented any of this yet.

Let’s get started with this (seemingly) simple change.

Using the LWC renderedCallback Lifecycle Hooks

If you’re a seasoned LWC vet, you know that for non-wired calls to Apex, you have to do two things:

  1. Use the renderedCallback LWC lifecycle hook
  2. Create a guard clause for renderedCallback so that the imperative Apex is only invoked once, as renderedCallback can run several times while the component is being loaded into the DOM, as well as whenever the component re-renders

So — a bit of ceremony to perform, but really nothing bad. The only other thing to note was the async decorator on the navToObject function — we’ll need to move that to our renderedCallback function since this is now where the Apex call is occurring:

// ...
export default class OurExampleComponent extends NavigationMixin(
  LightningElement
) {
  _isRendered;
  customObject;
  isNavToObjectDisabled;

  async renderedCallback() {
    if (!this._isRendered) {
      this._isRendered = true;
      this.customObject = await getCustomObject();
      this.isNavToObjectDisabled = !this.customObject || !this.customObject.Id;
    }
  }

  navToObject() {
    if (this.isNavToObjectDisabled) {
      return;
    }
    // store value locally to deal with "this"
    // reference changing with navigation mixin
    const objectId = this.customObject.Id;
    this[NavigationMixin.Navigate]({
      type: "standard__recordPage",
      attributes: {
        objectApiName: "OurCustomObjectName",
        actionName: "view",
        recordId: objectId
      }
    });
  }

Yes! Working with LWC unit tests is eas- oh wait. Hmm. The test is still failing. Why is the test still failing? A simple check within the UI itself shows that the component itself is working as expected; if the Apex controller returns null or an object without an Id, the button to navigate is properly disabled. If the Apex controller returns something with the an actual Id, the user is allowed to proceed with clicking the button and navigation completes successfully.

This is pretty much the opposite scenario from what we’d like to see - the production level code works, but the test is still broken. 🤔 This is an interesting case, and not at all a typical one — so we wrote another failing test that mimicked what we were seeing working within the UI:

it("navigates to custom object on view button click", async () => {
  const RECORD_ID = "000Z1000000zz0aAAA"; // some fake id
  getCustomObject.mockResolvedValue({ Id: RECORD_ID });

  const NAV_TYPE = "standard__recordPage";
  const NAV_OBJECT_API_NAME = "OurCustomObject__c";
  const NAV_ACTION_NAME = "view";

  const { element } = setupTest();

  return Promise.resolve().then(() => {
    const button = element.shadowRoot.querySelector(
      '[data-id="viewObjectButton"]'
    );
    button.click();

    const { pageReference } = getNavigateCalledWith();

    expect(pageReference.type).toBe(NAV_TYPE);
    expect(pageReference.attributes.objectApiName).toBe(NAV_OBJECT_API_NAME);
    expect(pageReference.attributes.actionName).toBe(NAV_ACTION_NAME);
    expect(pageReference.attributes.recordId).toBe(RECORD_ID);
  });
});

This test failed, as well.

Debugging LWC Jest Tests

The ability to debug your LWC unit tests locally — no internet connection required — is arguably the best feature that Lightning Web Components has to offer. You can prove that your code works from anywhere in the world. Setting a breakpoint within the navToObject click handler yielded little in the way of actionable information, though — all we could establish while my manager was driving was that this.customObject was undefined in the click handler. This somewhat defied comprehension; again, we knew it was assuredly not undefined since we could see the button working on the frontend.

I took over and found something interesting almost immediately in my own debugging — I put a breakpoint in renderedCallback as well as the existing breakpoint my manager had been using within the click handler aaaaaand … the click handler’s breakpoint was hit first. This was a puzzler. Just looking at the test, the order of operations didn’t seem to make any sense — first the click handler’s breakpoint would be hit, and then the renderedCallback breakpoint. The button was being clicked — according to the test — before the component had finished rendering!

After about a half hour of debugging between us, I started poring through the docs, which burned more time. There wasn’t anything immediately obvious that was wrong with what we were doing — and outside of the LWC Recipes repository, there aren’t many great resources for how to structure your Jest tests to correctly mimic your production-level setup.

In retrospect, the handler being invoked before renderedCallback could have been a big red flag — but because there isn’t much in the way of documentation about these methods, and because we could observe the desired result occurring within the UI, I can definitely say that my critical thinking skills felt hampered. Indeed, even prior to me taking the “driver’s seat” in our paired programming session, I had suggested what the actual “issue” might be to my manager — but it had felt so farfetched when I said it out loud, I couldn’t even bring myself to try it until we’d burned that extra half hour together. Don’t do what I did! If you have a hunch, act on it — that’s the beauty of TDD! Fast feedback makes even our worst ideas something we can act out in near-realtime, instead of trying to reason about what the issue might be.

I had said well, syntactically, async/await is the same as using promises (since that’s what async/await does under the hood to your functions) so it feels ridiculous that it might be our having annotated renderedCallback as async that’s causing this issue …

Making The Tests Pass

In the end, the “solution” (though I hesitate to call it that; more accurately, “the way we catered to the testing framework”) cost us very little:

// NB - no more async decorator
renderedCallback() {
  if (!this._isRendered) {
    this._isRendered = true;
    getCustomObject().then(customObject => {
      this.customObject = customObject;
      this._setViewObjectFlag();
    });

    this._setViewObjectFlag();
  }
}

_setViewOppsFlag() {
  if (!this.customObject && !this.customObject?.Id) {
    this.isNavToObjectDisabled = true;
  } else {
    this.isNavToObjectDisabled = false;
  }
}

By converting renderedCallback back to a pseudo-synchronous function, and using promises to imperatively invoke our Apex, both of the failing tests we’d written immediately started passing.

One Small Change Wrap-up

Though it cost us an hour, working on this “simple” story brought with it a few key reminders:

  • never underestimate simple requirements
  • remember the value of end-to-end tests (if we hadn’t check that things were actually working in the UI, we might have spent hours stuck in a false-positive feedback loop)

This article is another example of the XY problem. Despite the idiosyncracies of testing LWC using Jest, and the issues we experienced (which felt more like subtle differences between the runtime behavior of LWC and the testing framework implementation), being able to develop and test your components locally, with easy stubs provided for server-side calls, is a huge boon to both aspiring frontend devs and the experienced alike. Make LWC unit tests part of your (or your team’s) workflow, if you haven’t already! If you’d like to read another article where we see the ripple effects of “one small change,” be sure to check out Creating A Round Robin Assignment for more!

Thanks for reading — a short one, today, but hopefully a helpful one too if you’re trying to invoke Apex imperatively in order to load required information for your component and test it successfully. I have another post that I have been working on, about keeping your Invocable Apex code clean, but I’ve also been sidetracked frequently by the ongoing work on Rollup, which has been exciting and rewarding. If you haven’t checked out the repository, or haven’t visited it recently, I would highly advise checking out the Releases pane to see the iterative changes that have been made. If you’re currently using DLRS and are struggling with performance issues, or you’re paying for Rollup Helper and know there must be something better out there for free — there is. For more info, check out Replacing DLRS with Custom Rollup and the Github repo!

In the past three years, hundreds of thousands of you have come to read & enjoy the Joys Of Apex. Over that time period, I've remained staunchly opposed to advertising on the site, but I've made a Patreon account in the event that you'd like to show your support there. Know that the content here will always remain free. Thanks again for reading — see you next time!