Simple and effective: Unit-testing Alpine.js components with Jest ⏱️⏩
Tags: javascript, webdev, testing, codequality
Published: 2020-09-07 08:51:17

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 "code" with a few tools next to it](https://dev-to-uploads.s3.amazonaws.com/i/9d2dtxzxp13dl3ok60d3.png)

## 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`:

```bash
# CLI
npm install --save-dev jest
```

```JSON
/* package.json */
/* ... */
  "scripts": {
    "test": "./node_modules/.bin/jest test/"
  },
/* ... */
```

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
```

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

```html
<!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>
```

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`:

```javascript
// 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
}
```

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:

```html
<!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>
```

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:

```javascript
// 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
})
```

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

```javascript
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)
  })
})
```

## 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.

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

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.

```javascript
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')
  }
})
```

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:

```javascript
// ...
  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)
  })
// ...
```

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

```javascript
// ...
  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')
  })
// ...
```

Implementing `$nextTick` can be done in a similar fashion:

```javascript
const nextTickMock = jest.fn()
  .mockImplementation(
    callback => callback()
  )
```

`$watch` will be a little more complex, though:

```javascript
// 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))
}
```

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](https://dev-to-uploads.s3.amazonaws.com/i/pr8i8zqofpus1rz668bl.png)

## 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:

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

window.axios = require('axios')
```

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](https://dev-to-uploads.s3.amazonaws.com/i/jjho47gfk49w5qhqaz06.png)

## 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](https://dev.to/hugo__df): [github.com/HugoDF/alpine-test-utils](https://github.com/HugoDF/alpine-test-utils)

## Further links

 * [Alpine.js Github repo](https://github.com/alpinejs/alpine)
 * [Jest documentation](https://jestjs.io/en/)
 * [Using Jest with Puppeteer](https://jestjs.io/docs/en/puppeteer)

---

I write tech articles in my free time. If you enjoyed reading this post, consider [buying me a coffee](http://buymeacoffee.com/pthormeier)!

[![Buy me a coffee button](https://dev-to-uploads.s3.amazonaws.com/i/x55hp6jopwyy161d8e2u.png)](http://buymeacoffee.com/pthormeier)