Frontend Development#

Developer or User?

This section is part of the instrutions for programmers interested in REEV. If you want to use REEV, the best place is to start at Quickstart.

This section describes the best practices to use for frontend development.

Import Order#

Import order should be (and is also enforced by prettier):

  • external packages

  • project includes with @/ prefix

  • relative includes with ./ prefix

Overall, restrict relative include order for tests to include code to be tested with ../.

Types#

  • For the Vue reactive stores, explicitely specify the type in the creating function, e.g., ref<string> or ref<string | undefined>.

  • In many places in the UI, using undefined is more appropriate than null as this is the value before the data is loaded from the backend.

Test Structure#

Consider the following structure, an example is given below.

  • imports

  • define fixture data
    • put larger fixture data into .json files within the __tests__ folders (will go to LFS by our .gitattributes configuration)

  • define the tests

  • use assemble, act, assert structure e.g., as described here
    • guard assertions are OK

  • use describe.concurrent to describe the tests, usually one block per .spec.ts file

  • use it to define the tests
    • use async () => { ... } only when necessary, e.g., for await nextTick()

  • use the setupMountedComponents() helper from @/components/__tests__/utils to mount components, setup store, and setup router with mocks

import { setupMountedComponents } from '@bihealth/reev-frontend-lib/lib/testUtils'
import { describe, expect, it } from 'vitest'
import { h, nextTick } from 'vue'

import { type UserData, useUserStore } from '@/stores/user'

import UserProfileButton from './UserProfileButton.vue'

/** Example User data */
const adminUser: UserData = {
  id: '2c0a153e-5e8c-11ee-8c99-0242ac120002',
  email: 'admin@example.com',
  is_active: true,
  is_superuser: true,
  is_verified: true,
  oauth_accounts: [
    {
      id: '2c0a153e-5e8c-11ee-8c99-0242ac120002',
      oauth_name: 'google',
      account_id: '1234567890',
      account_email: 'admin@example.com'
    }
  ]
}

/** Dummy routes for testing. */
const dummyRoutes = [
  {
    path: '/',
    name: 'home',
    component: h('div', { innerHTML: 'for testing' })
  },
  {
    path: '/login',
    name: 'login',
    component: h('div', { innerHTML: 'for testing' })
  },
  {
    path: '/profile',
    name: 'profile',
    component: h('div', { innerHTML: 'for testing' })
  }
]

describe.concurrent('UserProfileButton', () => {
  it('displays Login button without any user', async () => {
    // arrange:
    const { wrapper } = await setupMountedComponents(
      { component: UserProfileButton },
      {
        initialStoreState: {
          user: {
            currentUser: null
          }
        },
        routes: dummyRoutes
      }
    )

    // act: nothing, only test rendering

    // assert:
    const loginButton = wrapper.findComponent('#login')
    expect(loginButton.exists()).toBe(true)
    const logoutButton = wrapper.findComponent('#profile')
    expect(logoutButton.exists()).toBe(false)
  })

  it('displays Profile button with a user', async () => {
    // arrange:
    const { wrapper } = await setupMountedComponents(
      { component: UserProfileButton },
      {
        initialStoreState: {
          user: {
            currentUser: adminUser
          }
        },
        routes: dummyRoutes
      }
    )

    // act: nothing, only test rendering

    // assert:
    const loginButton = wrapper.findComponent('#login')
    expect(loginButton.exists()).toBe(false)
    const logoutButton = wrapper.findComponent('#profile')
    expect(logoutButton.exists()).toBe(true)
  })

  it('switches from Login to Profile button when store changes', async () => {
    // arrange:
    // Note that we use an `async` test here as we need `await nextTick()` for the DOM
    // update to bubble through when updating the state property.
    const { wrapper } = await setupMountedComponents(
      { component: UserProfileButton },
      {
        initialStoreState: {
          user: {
            currentUser: null
          }
        },
        routes: dummyRoutes
      }
    )

    // act:
    let loginButton = wrapper.findComponent('#login')
    expect(loginButton.exists()).toBe(true)
    let logoutButton = wrapper.findComponent('#profile')
    expect(logoutButton.exists()).toBe(false)

    const userStore = useUserStore()
    userStore.currentUser = adminUser

    await nextTick()

    // assert:
    loginButton = wrapper.findComponent('#login')
    expect(loginButton.exists()).toBe(false)
    logoutButton = wrapper.findComponent('#profile')
    expect(logoutButton.exists()).toBe(true)
  })
})

Note that there is a separation between views and components (cf. https://stackoverflow.com/a/50866150/84349) that is also reflected in the tests.

  • The main purpose of views is to handle the routing.

  • The actual work happens in components.

In tests, we thus use stubbed out of nested components in views but not components. This keeps the load time acceptable while testing the components in isolation.