DEV Community

Cover image for Simple and effective: Unit-testing Alpine.js components with Jest ⏱️⏩
Pascal Thormeier
Pascal Thormeier

Posted on • Edited on

Simple and effective: Unit-testing Alpine.js components with Jest ⏱️⏩

Alpine.js is an amazing framework. "Think of it like Tailwind for JavaScript". And boy, does it deliver! Alpine offers reactivity and the declarative nature of bigger frameworks, but without the need to create SPAs or to learn things like hooks and whatnot. I'm a big fan.

One thing that is not covered by the Alpine docs, though, is testing. In this article I'll explore a possible approach to make Alpine components testable and to unit-test them using Jest.

Unanswered questions I'll answer

If your component has a lot of business logic, you would want to write tests for it. Sure, some components are small enough to omit testing altogether (you would be testing the language and the framework, really), but what about more complex components? Where do we draw the line?

Another thing to consider: Since all of the logic and reactivity lives on the DOM, how do we untie this? How do we get something testable out of a mixture of HTML and JS?

How do we make these tests meaningful and useful?

To answer these questions, I'll do a practical example.


An icon showing

Prepare the code

For this example I will assume that we already installed and are using Alpine. So let's install Jest first and add a test command to the package.json:

# CLI
npm install --save-dev jest
Enter fullscreen mode Exit fullscreen mode
/* package.json */
/* ... */
  "scripts": {
    "test": "./node_modules/.bin/jest test/"
  },
/* ... */
Enter fullscreen mode Exit fullscreen mode

You'll notice the test folder I used in the Jest command - let's add that and a src folder as well:

mkdir src
mkdir test
Enter fullscreen mode Exit fullscreen mode

Now let's have a look at our current app. It has a tab navigation and three subpages. The Alpine component is inlined.

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <div x-data="{
      tabs: ['Home', 'Contact', 'Newsletter'],
      activeTab: 0,
      switchTab: function (tab) {
        let tabIndex = this.tabs.indexOf(tab)
        if (tabIndex === -1) {
          tabIndex = 0
        }

        this.activeTab = tabIndex
      }
    }">
      <!-- Navigation -->
      <template x-for="(tab, index) in tabs">
        <button
          :class="{ 'active': index === activeTab }"
          @click="switchTab(tab)"
          x-text="tab"
        ></button>
      </template>

      <!-- Content -->
      <div x-show="activeTab === 0">
        <h1>Home</h1>
        <p>Lorem ipsum dolor sit amet</p>
      </div>
      <div x-show="activeTab === 1">
        <h1>Contact</h1>
        <p>Lorem ipsum dolor sit amet</p>
      </div>
      <div x-show="activeTab === 2">
        <h1>Newsletter</h1>
        <p>Lorem ipsum dolor sit amet</p>
      </div>
    </div>

    <script
      src="./node_modules/alpinejs/dist/alpine.js"
    ></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The Alpine-component is pretty straight forward: It has a list of tabs, keeps track of which tab is active and has a method to switch tabs with some validation in it.

To get this testable, we need to extract this component and move it to its own JS-file called src/tabNavigation.js:

// Our main component
// Now a function returning the same definition as before.
const tabNavigation = () => ({
  tabs: ['Home', 'Contact', 'Newsletter'],
  activeTab: 0,
  switchTab: function (tab) {
    let tabIndex = this.tabs.indexOf(tab)
    if (tabIndex === -1) {
      tabIndex = 0
    }

    this.activeTab = tabIndex
  }
})

// Necessary for the browser
if (window) {
  window.tabNavigation = tabNavigation
}

// To import the component later in the test
if (module) {
  module.exports = tabNavigation
}
Enter fullscreen mode Exit fullscreen mode

Not only is this a lot more readable, we also made the component testable. As a neat side effect, the IDE can now pick this up as actual JS. The logic itself stays the same, we only decoupled it from the DOM:

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <div x-data="tabNavigation()">
      <!-- ... rest of the component ...  -->
    </div>

    <script src="src/tabNavigation.js"></script>
    <script
      src="./node_modules/alpinejs/dist/alpine.js"
    ></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Generally, if your component has enough logic to be more readable when it lives in its own file, it probably should. At some point you'll develop a gut feeling as to when to split things up. Separation of concerns and clean code help a lot here.

With this setup I can start to:

Write some tests

From here on we can start writing tests. Let's start with this frame:

// Import the component
const tabNavigation = require('../src/tabNavigation')

describe('Tab navigation', () => {
  let instance

  // Have a fresh instance for every test
  beforeEach(() => {
    instance = tabNavigation()
  })

  // TODO: Write tests here
})
Enter fullscreen mode Exit fullscreen mode

Since the component does not directly depend on Alpine itself, we can test its behavior using this instance:

const tabNavigation = require('../src/tabNavigation')

describe('Tab navigation', () => {
  let instance

  beforeEach(() => {
    instance = tabNavigation()
  })

  test('Should switch tabs', () => {
    expect(instance.activeTab).toBe(0)

    instance.switchTab('Contact')
    expect(instance.activeTab).toBe(1)

    instance.switchTab('Newsletter')
    expect(instance.activeTab).toBe(2)
  })

  test('Should fallback to home', () => {
    instance.switchTab('Contact')
    expect(instance.activeTab).toBe(1)

    instance.switchTab(null)
    expect(instance.activeTab).toBe(0)
  })
})
Enter fullscreen mode Exit fullscreen mode

Mocking magic properties

Let's enhance the component a bit more by making it configurable. I'll add a x-init call and some data-attribute with tabs.

<!-- ... -->
<div 
  x-data="tabNavigation($dispatch)" 
  x-init="init()" 
  data-tabs='["Home", "Contact", "Newsletter"]'
>
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Alpine offers a total of six magic properties/functions. They offer some extra functionality that is useful to further interact with the DOM.

Now I add the implementation of the init-method: Reading out the content of data-tabs, parsing it and dispatching an event afterwards.

const tabNavigation = ($dispatch) => ({
  $dispatch: $dispatch,
  tabs: [],
  activeTab: 0,
  init: function () {
    this.tabs = JSON.parse(this.$el.dataset.tabs)

    this.$dispatch('tabsInitialized')
  },
  switchTab: function (tab) {
    let tabIndex = this.tabs.indexOf(tab)
    if (tabIndex === -1) {
      tabIndex = 0
    }

    this.activeTab = tabIndex
    this.$dispatch('tabSwitched')
  }
})
Enter fullscreen mode Exit fullscreen mode

Now I've created a direct dependency to Alpine by using $el (a magic property to access the DOM element the component was initialized on) and $dispatch (a magic method to dispatch events).

To accurately test these, we need to mock them in the beforeEach in our test:

// ...
  let instance

  // Keep track of the mocked $dispatch
  let dispatch

  beforeEach(() => {
    // Mock $dispatch
    dispatch = jest.fn()

    instance = tabNavigation(dispatch)

    // Mock $el with some dataset
    instance.$el = {
      dataset: {
        tabs: JSON.stringify([
          'Home', 
          'Contact', 
          'Newsletter',
        ])
      }
    }

    // Call init() of the component to set everything up
    instance.init()
  })

  test('Should have dispatched an init event', () => {
    expect(dispatch).toBeCalledWith('tabsInitialized')
    expect(instance.tabs.length).toBe(3)
  })
// ...
Enter fullscreen mode Exit fullscreen mode

Let's also test if the tabSwitch event got dispatched at all when switching tabs:

// ...
  test('Should switch tabs', () => {
    expect(instance.activeTab).toBe(0)

    instance.switchTab('Contact')
    expect(instance.activeTab).toBe(1)
    expect(dispatch).toBeCalledWith('tabSwitched')

    instance.switchTab('Newsletter')
    expect(instance.activeTab).toBe(2)
    expect(dispatch).toBeCalledWith('tabSwitched')
  })

  test('Should fallback to home', () => {
    instance.switchTab('Contact')
    expect(instance.activeTab).toBe(1)

    instance.switchTab(null)
    expect(instance.activeTab).toBe(0)
    expect(dispatch).toBeCalledWith('tabSwitched')
  })
// ...
Enter fullscreen mode Exit fullscreen mode

Implementing $nextTick can be done in a similar fashion:

const nextTickMock = jest.fn()
  .mockImplementation(
    callback => callback()
  )
Enter fullscreen mode Exit fullscreen mode

$watch will be a little more complex, though:

// List of watchers to keep track
const watchers = {}

// The actual mock
const watchMock = jest.fn()
  .mockImplementation((field, callback) => {
    watchers[field] = watchers[field] || []
    watchers[field].push(callback)
  })

// Convenience function to trigger all watchers 
// for a specific field.
const executeAllWatchers = (field, value) => {
  watchers[field].forEach(watcher => watcher(value))
}
Enter fullscreen mode Exit fullscreen mode

Pretty neat! With this set of mocks I can write tests for all kinds of Alpine components and really validate their internal logic.


A hole in the ground with a warning sign next to it

Common pitfalls

The Tailwind-like nature of Alpine and its decentralized approach make testing a bit harder. It's therefore important to know about some common pitfalls and how to mitigate their risks.

Testing the framework

Because of Alpine living close to or on the DOM, the first impulse might be to write tests for all of the directives and listeners used. I personally would expect @click to execute the given code when an element is clicked. I don't need to test this. If you want to test if the correct element is clickable, though, you might want integration tests instead.

I recommend using Jest with Puppeteer or JSDOM to achieve this. You can also use these to test components that are still entirely living on your DOM. I linked a test utils package mentioned in the comments further below.

Not mocking dependencies

When your dependencies live on the window element, you would want to mock those too. When the dependencies are not explicit (for example via dependency injection on the component constructor), it can be easy to forget about them, resulting in weird and unwanted behavior.

This example is mocking axios to be used by components via a global variable:

jest.mock('axios', () => ({
  get: jest.fn().mockImplementation(...),
}))

window.axios = require('axios')
Enter fullscreen mode Exit fullscreen mode

Now all of the component's calls to axios will be mocked.

Not testing possible states

Alpine components usually have state. An input by the user can change this state. But DOM-manipulation by some other component or even entirely different libraries can change the state of your component, too.

Let's think of the tabs component again. While not giving the user the possibility to select anything else than the given tabs, some outside DOM manipulation might add another tab. Write tests for invalid or unexpected input as well.


Thought bubble containing a path with three little flags on it

Takeaway thoughts

While Alpine is a perfect tool for prototyping, it can also be used in larger projects. Especially those large projects require testing, which is perfectly possible with Jest alone.

The effort required to set everything up is rather small: No extra plugins, no extra libraries. But the benefits are huge!

As mentioned in the comments, another way to write tests for Alpine components, including the DOM part, is this package by @hugo__df: github.com/HugoDF/alpine-test-utils

Further links


I write tech articles in my free time. If you enjoyed reading this post, consider buying me a coffee!

Buy me a coffee button

Top comments (3)

Collapse
 
hugo__df profile image
Hugo Di Francesco

Nice writeup in terms of writing isolated unit tests for component logic.

For anyone interested in testing the full component (including rendering logic) I've purpose built the following package github.com/HugoDF/alpine-test-utils.

Collapse
 
thormeier profile image
Pascal Thormeier • Edited

That's an awesome package you built there, a much simpler alternative to using Puppeteer! Are calls of magic methods also inspectable or do they still require some manual labour to be mocked?

Mind if I add a link to the package to the post itself?

Collapse
 
hugo__df profile image
Hugo Di Francesco

I haven't tried to mock the magic methods, but all the stuff your code sticks on the instance is exposed under $data

A link in the post would be great!

Also a quick note re- puppeteer, Alpine.js itself is tested using Jest with baked in JSDOM (& that's also what test-utils uses) so that's another piece of the puzzle between JS-only unit tests and E2E tests with puppeteer or Cypress