Skip to content

UI Tests

Leonardo da Rosa Silveira João edited this page Jul 15, 2021 · 2 revisions

Introduction

This document was prepared by the project's collaborators with the aim of improving the development of activities related to the ShellHub UI. To optimize test development, the ShellHub team decided to register the UI test pattern here. This document aims to cover the most important topics, assist with corrections and make the entire process more efficient.

About Tests

Unit testing is a software test in which individual software units are tested. For this, the purpose of the unit test is valid if each unit of the software works as designed.

The ShellHub project adopted the Jest framework, it is a universal testing platform, with the ability to adapt to any JavaScript library or framework. On this link you can check the documentation, Vue Test Utils and Vue Testing Handbook.

General Considerations

This section was created to present the items and standards that should be considered when implementing tests, such as:

  • The tests must be created according to the order defined in the document, for this: (i) Component Rendering; (ii) Data and Props Verification, in this case the internal order is defined by the sequence of implementation that eslint itself performs in the component; and (iii) HTML validation;

  • Implement all tests related to the current context (wrapper), to later make the other tests related to other contexts;

  • In cases where it is necessary to use foreach to optimize the items to be tested, add them to the related test. The test aims to verify a certain set of items, using (expect) for this.;

  • Whenever possible, try to declare the variables used at file scope, improving code visualization. Getting practically the (expect) within each test performed;

  • When it is necessary to create another context (wrapper) to test different items, specify what has been modified in the test name. In the example, you can check the idea used;

it('Renders the template with components - device offline', async () => {
...
});
  • In cases where the test modifies several fields, it adds comma and newline to each item citing the modified items. In the example, you can check the idea used;
it(`Renders the template with components - 
    device offline,
    is owner,
    dialog is true`,
async () => {
...
});
  • Whenever necessary, try to reuse the variables declared in the tests, especially with regard to lists and objects, copying and changing only what is necessary. Example of copying an object;
const obj = { uid: 'uid', online: false };
const copyObj = { ...obj, online: true } ;
  • Example of copying an object list;
const objList = [{ uid: 'uid', online: false }, { uid: 'uid', online: false }];
const copyObjList = JSON.parse(JSON.stringify(objList));
copyObjList[1].online = true;

This is a copy template that must be used in the ShellHub UI test project. In the presented case, the size of the object structure and/or object list is relatively small considering that they are handled in the tests. But in larger structures it is possible to see the advantage of performing this type of procedure. Finally, always make this assessment judiciously.

Code Exemple

Below is an example of code to apply the standard proposed by this document.

<template>
  <fragment>
    <v-tooltip bottom>
      <template #activator="{ on }">
        <span v-on="on">
          <v-icon
            :disabled="!isOwner"
            v-on="on"
            @click="dialog = !dialog"
          >
            mdi-pencil
          </v-icon>
        </span>
      </template>

      <div>
        <span
          v-if="isOwner"
          data-test="tooltipOwner-text"
        >
          Edit
        </span>

        <span v-else>
          You are not the owner of this namespace
        </span>
      </div>
    </v-tooltip>

    <v-dialog
      v-model="dialog"
      max-width="450"
      @click:outside="cancel"
    >
      <v-card data-test="deviceRename-dialog">
        <v-card-title class="headline grey lighten-2 text-center">
          Rename Device
        </v-card-title>
        <ValidationObserver
          ref="obs"
          v-slot="{ passes }"
        >
          <v-card-text class="caption mb-0">
            <ValidationProvider
              v-slot="{ errors }"
              ref="providerHostname"
              name="Hostname"
              rules="required|rfc1123|noDot"
              vid="hostname"
            >
              <v-text-field
                v-model="editName"
                label="Hostname"
                :error-messages="errors"
                require
                :messages="messages"
              />
            </ValidationProvider>
          </v-card-text>
          <v-card-actions>
            <v-spacer />
            <v-btn
              text
              data-test="cancel-btn"
              @click="cancel"
            >
              Close
            </v-btn>
            <v-btn
              color="primary"
              text
              data-test="rename-btn"
              @click="passes(edit)"
            >
              Rename
            </v-btn>
          </v-card-actions>
        </ValidationObserver>
      </v-card>
    </v-dialog>
  </fragment>
</template>
<script>

import {
  ValidationObserver,
  ValidationProvider,
} from 'vee-validate';

export default {
  name: 'DeviceRename',

  components: {
    ValidationProvider,
    ValidationObserver,
  },

  props: {
    name: {
      type: String,
      required: true,
    },
    uid: {
      type: String,
      required: true,
    },
  },

  data() {
    return {
      dialog: false,
      invalid: false,
      editName: '',
      messages: 'Examples: (foobar, foo-bar-ba-z-qux, foo-example, 127-0-0-1)',
    };
  },

  computed: {
    device: {
      get() {
        return {
          name: this.name,
          uid: this.uid,
        };
      },
    },

    isOwner() {
      return this.$store.getters['namespaces/owner'];
    },
  },

  created() {
    this.editName = this.device.name;
  },

  updated() {
    this.editName = this.device.name;
  },

  methods: {
    cancel() {
      this.dialog = false;
      this.invalid = false;
      this.editName = '';
    },

    async edit() {
      try {
        await this.$store.dispatch('devices/rename', {
          uid: this.device.uid,
          name: this.editName,
        });
        this.dialog = false;
        this.$emit('new-hostname', this.editName);
        this.editName = '';
        this.$store.dispatch('snackbar/showSnackbarSuccessAction', this.$success.deviceRename);
      } catch (error) {
        if (error.response.status === 409) {
          this.$refs.obs.setErrors({
            hostname: ['The name already exists in the namespace'],
          });
        } else {
          this.$store.dispatch('snackbar/showSnackbarErrorAction', this.$errors.snackbar.deviceRename);
        }
      }
    },
    
    // Function is not used in the code, was inserted to explain how the test can be performed
    displayOnlyTenCharacters(str) {
      if (str !== undefined) {
        if (str.length > 10) return `${str.substr(0, 10)}...`;
      }
      return str;
    },
  },
};

</script>

This component was chosen because it covers several tests that can be performed.

import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import DeviceRename from '@/components/device/DeviceRename';
import { ValidationProvider, ValidationObserver } from 'vee-validate';
import flushPromises from 'flush-promises';
import Vuetify from 'vuetify';
import '@/vee-validate';

describe('DeviceRename', () => {
  const localVue = createLocalVue();
  const vuetify = new Vuetify();
  localVue.use(Vuex);
  localVue.component('ValidationProvider', ValidationProvider);
  localVue.component('ValidationObserver', ValidationObserver);

  let wrapper;

  let isOwner = true;
  const uid = 'a582b47a';
  const name = '39-5e-2a';
  
  const invalidNames = [
    '\'', '"', '!', '@', '#', '$', '%', '¨', '&', '*', '(', ')', '-', '_',     '=', '+', '´', '`', '[',
    '{', '~', '^', ']', ',', '<', '..', '>', ';', ':', '/', '?',
  ];

  const store = new Vuex.Store({
    namespaced: true,
    state: {
      isOwner,
    },
    getters: {
      'namespaces/owner': (state) => state.isOwner,
      'devices/get': (state) => state.device,
    },
    actions: {
      'devices/rename': () => {
      },
      'snackbar/showSnackbarSuccessAction': () => {
      },
      'snackbar/showSnackbarErrorAction': () => {
      },
    },
  });

  beforeEach(() => {
    wrapper = mount(DeviceRename, {
      store,
      localVue,
      stubs: ['fragment'],
      propsData: { name, uid },
      vuetify,
    });
  });

  // This is where the tests are entered
});

Tests

///////
// Component Rendering
//////

it('Is a Vue instance', () => {
  expect(wrapper).toBeTruthy();
});

it('Renders the component', () => {
  expect(wrapper.html()).toMatchSnapshot();
});

///////
// Data and Props checking
//////

it('Receive data in props', () => {
  expect(wrapper.vm.name).toEqual(name);
  expect(wrapper.vm.uid).toEqual(uid); 
});

it('Compare data with default value', () => {
  expect(wrapper.vm.dialog).toEqual(false);
  expect(wrapper.vm.invalid).toEqual(false);
  expect(wrapper.vm.editName).toEqual(name);
});

it('Process data in the computed', () => {
  expect(wrapper.vm.tenant).toEqual(tenant);
});

it('Process data in methods', () => {
  // In the example, a parameter is passed in the method call, if it is not
  // necessary to pass an argument, you can do the test as follows:
  // expect(wrapper.vm.method).toEqual();
  expect(wrapper.vm.displayOnlyTenCharacters(name)).toEqual(name);
});

//////
// HTML validation
//////

it('Show message tooltip - user is owner', async (done) => {
  const icons = wrapper.findAll('.v-icon');
  const helpIcon = icons.at(0);
  helpIcon.trigger('mouseenter');
  await wrapper.vm.$nextTick();

  expect(icons.length).toBe(1);
  requestAnimationFrame(() => {
    expect(wrapper.find('[data-test="tooltipOwner-text"]').text()).toEqual('Edit');
    done();
  });
});

it('Show validation messages - fields required', async () => {
  wrapper.setData({ dialog: true });
  await flushPromises();

  wrapper.setData({ editName: '' });
  await flushPromises();

  const validator = wrapper.vm.$refs.providerHostname;

  await validator.validate();
  expect(validator.errors[0]).toBe('This field is required');
});

it('Show validation messages - error for dot', async () => {
  wrapper.setData({ dialog: true });
  await flushPromises();

  wrapper.setData({ editName: '' });
  await flushPromises();

  const validator = wrapper.vm.$refs.providerHostname;

  await validator.validate();
  expect(validator.errors[0]).toBe('The name must not contain dots');
});

it('Show validation messages - invalid RFC1123', async () => {
  wrapper.setData({ dialog: true });
  await flushPromises();
  
  invalidNames.forEach((name) => {
    wrapper.setData({ editName: name });
    await flushPromises();
    
    const validator = wrapper.vm.$refs.providerHostname;
    
    await validator.validate();
    expect(validator.errors[0]).toBe('You entered an invalid RFC1123 name');
  });
});

it('Renders the template with components', () => {
  // Example test to be performed when there is a component and you want to check if 
  // it exists. Or even that the component should not be rendered by the logic used. 
  expect(wrapper.find('[data-test="boxMessageDevice-component"]').exists()).toBe(false);
});

it('Renders the template with data', () => {
  // This case is made to check if you are rendering just the icon. Because in the 
  // beginning the dialog is false.
  expect(wrapper.find('[data-test="deviceRename-dialog"]').exists()).toEqual(false);
});

it('Renders the template with data - dialog is true', async () => {
  wrapper.setData({ dialog: true });
  await flushPromises();

  expect(wrapper.find('[data-test="deviceRename-dialog"]').exists()).toEqual(true);
  expect(wrapper.find('[data-test="rename-btn"]').exists()).toEqual(true);
  expect(wrapper.find('[data-test="cancel-btn"]').exists()).toEqual(true);
});

it('Renders the template with data - does not own', async () => {
  // This is a simple case that only checks for icon rendering, even when the user
  // doesn't have the namespace. But it's an example of another wrapper being
  // created with a store change.
  isOwner = false;
  
  wrapper = mount(DeviceRename, {
    store,
    localVue,
    stubs: ['fragment'],
    propsData: { name, uid },
    vuetify,
  });
  
  expect(wrapper.find('[data-test="deviceRename-dialog"]').exists()).toEqual(false);
});
Clone this wiki locally