Kliensoldali webprogramozás

Tesztelés

Horváth Győző
Egyetemi docens
1117 Budapest, Pázmány Péter sétány 1/c., 2.408-as szoba
Tel: (1) 372-2500/8469
horvath.gyozo@inf.elte.hu

Ismétlés

Komponensalapú webfejlesztés

  • React
    • komponensek
    • adatok
  • Redux
    • store
    • reducer
    • action
    • action creator
  • Middleware
    • szinkron
    • aszinkron

Kommunikációs formák

  • HTTP alapú (kérés-válasz)
    • HTML
    • AJAX
      • JSON
      • REST
      • GraphQL
    • SSE
  • Websockets (nem HTTP)
  • WebRTC (peer-to-peer)

Tesztelés ismétlés

Konzol

  • Tesztelés a konzolon
  • Tesztelés a kódban
function add(a, b) {
  return a + b;
}
console.assert(add(3, 2)  === 5,   '3 + 2 should be equal 5');
console.assert(add(10, 0) === 10,  '10 + 0 should be equal 10');

Keretrendszer használata

Jasmine

<!-- keretrendszer betöltése -->
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/jasmine.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/jasmine.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/jasmine-html.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/boot.min.js"></script>

<!-- az alkalmazás és teszt betöltése -->
<script src="app.js"></script>
<script src="app.test.js"></script>
// app.test.js
describe('factorial', () => {
    it('0! should be 1', () => {
        expect(factorial(0)).toBe(1);
    })
})

UI tesztelés

<!-- keretrendszer betöltése -->
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/jasmine.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/jasmine.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/jasmine-html.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.4.0/boot.min.js"></script>

<input type="text" id="nev"> <br>
<input type="button" value="Köszönj!" id="gomb"> <br>
<span id="kimenet"></span>

<!-- az alkalmazás és teszt betöltése -->
<script src="hello.js"></script>
<script src="hello.test.js"></script>

Tesztek

describe('felület működése', () => {
    // saját $ függvény
    function $(sel) { return document.querySelector(sel); }
    // minden teszt előtt készítsük elő a felületet
    beforeEach(() => {
        $('#nev').value = '';
        $('#kimenet').innerHTML = '';
    });
    // tesztek
    it(`Minden megjelenik`, () => {
        expect($('input#nev')).not.toBeNull();
        expect($('input#gomb')).not.toBeNull();
        expect($('span#kimenet')).not.toBeNull();
    });
    it(`Üresen hagyva Hello ! jelenik meg`, () => {
        $('#gomb').click(); // elvégez
        expect($('#kimenet').textContent).toBe('Hello !'); // ellenőriz
    });
    it(`Szöveget írva be helyes üdvözlés lesz`, () => {
        $('#nev').value = 'alma'; // előkészít
        $('#gomb').dispatchEvent(new Event('click')); // elvégez
        expect($('#kimenet').textContent).toBe('Hello alma!'); // ellenőriz
    });
});

Tesztelés általában

Tesztelési szintek

  • Statikus kódelemzés
  • Egységtesztelés
  • Integrációs tesztelés
  • Funkcionális (end-to-end, e2e) tesztelés
  • Hatékonysági tesztelés

Tesztelési szintek

Statikus tesztelés

  • Statikus kódelemző eszközök
  • Szintaktikus hibák
  • Elírások
  • Típushibák
  • Hibára vezető kódrészletek
// ESLint: for-direction rule
for (var i = 0; i < 10; i--) {
  console.log(i)
}

// TypeScript
const two = '2'
const result = add(1, two)

Egységtesztelés

  • Önálló, izolált részek tesztelése
  • Függvények, osztályok
  • Leggyakoribb
  • Gyorsnak kell lennie
  • Függőségek helyettesítése
  • Folyamatosan fut

Integrációs tesztelés

  • Egységek együttműködésének vizsgálata
  • Függőségek nagy része marad
    • Adatbázis, hálózat, animáció nem kell
  • Gyorsak
  • Hatókör: kicsi → nagy
  • Szélsőséges megközelítések
    • lentről felfele tesztelés
    • fentről lefele tesztelés
  • Folyamatosan fut

Funkcionális tesztelés

  • End-to-end, e2e tesztelés
  • Felhasználó működésének szimulálása
  • Frontend + backend együtt
  • Nincs helyettesítés
  • Lassú, erőforrás- és költségigényes
  • Publikálás előtt fut

Hatékonysági tesztelés

  • mennyire hibatűrő az alkalmazás
  • hogyan viselkedik terhelés alatt

Kompromisszumok

Tesztelési módszertanok

  • Vízesés modell
    • tesztelés a fejlesztés után
  • Tesztvezérelt fejlesztés (TDD)
    • test-fail-code-pass-refactor
    • nincs túltervezés
    • csak a szükséges kód kerül be
    • használat definiálja az interfészt

Eszközök

Statikus kódelemzés

  • ESLint (+ Prettier)
  • StandardJS (linter + style guide + formatter)
  • TSLint

Testreszabható szabályokkal

Teszt keretrendszerek

  • Jest
  • Mocha + chai
  • Jasmine
  • Karma
  • Tape
  • Ava
  • QUnit

Futtatókörnyezet

  • Böngésző
    • Jasmine, Mocha, QUnit
  • Parancssor (Node.js)
    • Jest, Mocha, Jasmine
  • Headless böngésző
    • Puppeteer
  • Szimulált böngésző
    • jsdom

Segédeszközök

  • Mock libraries
    • Sinon.js
    • Időzítő, HTTP
  • Tesztelő segédfüggvények
    • DOM Testing Library
// sinon.js fake timer example
{
  setUp: function () {
    this.clock = sinon.useFakeTimers();
  },

  tearDown: function () {
    this.clock.restore();
  },

  "test should animate element over 500ms" : function(){
    var el = jQuery("<div></div>");
    el.appendTo(document.body);

    el.animate({ height: "200px", width: "200px" });
    this.clock.tick(510);

    assertEquals("200px", el.css("height"));
    assertEquals("200px", el.css("width"));
  }
}

Egység- és integrációs tesztelés

Jest

  • Facebook
  • Tesztfuttató keretrendszer
  • Parancssorban, Node.js
  • Egyik legnépszerűbb könyvtár
  • Kényelmes, egyszerű, sok mindenre felkészített
  • Jó mintákat vették át
  • React teszteléshez ajánlott

Unit

// math.js
export const add = (a, b) => a + b
export const multiply = (a, b) => a * b
// math.test.js
import { add, multiply } from "./math";

it("should add two positive numbers", () => {
  // Arrange
  const a = 10;
  const b = 32;
  // Act
  const sum = add(a, b);
  // Assert
  expect(sum).toEqual(42);
});
it("should add two negative numbers", () => {
  expect(add(-3, -4)).toBe(-7);
});

Csoportosítás

// math.test.js
import { multiply } from "./math";

describe('multiply', () => {
  it("should multiply two negative numbers", () => {
    expect(multiply(-3, -4)).toBe(12);
  });
  it("should multiply two positive numbers", () => {
    expect(multiply(3, 6)).toBe(18);
  });
  it("should multiply a positive and a negative number", () => {
    expect(multiply(-3, 4)).toBe(-12);
  });
  
  describe('subcatgeory', () => {
    it('should test', () => {
      expect(true).toBe(true);
    })
  })
});

Előkészületek és utómunkák

beforeAll(() => { /* ... */ });
afterAll(() => { /* ... */ });

beforeEach(() => { /* ... */ });
afterEach(() => { /* ... */ });

describe('Nested block', () => {
  beforeAll(() => { /* ... */ });
  afterAll(() => { /* ... */ });
  
  beforeEach(() => { /* ... */ });
  afterEach(() => { /* ... */ });
});

Expect + matchers

expect(2 + 2).toBe(4);
expect(2 + 2).not.toBe(4);
expect({one: 1, two: 2}).toEqual({one: 1, two: 2});

// Numbers
const value = 2 + 2;
expect(value).toBe(4);
expect(value).toEqual(4);
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);

// Strings
expect('Christoph').toMatch(/stop/);
expect('team').not.toMatch(/I/);

// Arrays
expect(shoppingList).toContain('beer');

// Errors
expect(compileAndroidCode).toThrow();

Dokumentáció

Mocks

  • Helyettesítő függvények
    • implementáció nélkül
    • implementációval
    • hívás száma, paramétere, visszatérési értéke vizsgálható/megadható
  • Meglévő függvények figyelése/cseréje
    • spy
  • Egész modulok cseréje
  • Nehéz téma

Mocks

// users.js
import axios from 'axios';
class Users {
  static all() {
    return axios.get('/users.json').then(resp => resp.data);
  }
}
export default Users;
// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios'); // automocking

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  axios.get.mockImplementation(() => Promise.resolve(resp))
  return Users.all().then(data => expect(data).toEqual(users));
});

Szinkron action creator

export const SAVE_ITEM_REQUEST = 'SAVE_ITEM_REQUEST'
export const SAVE_ITEM_SUCCESS = 'SAVE_ITEM_SUCCESS'
export const saveItemRequest = () => ({
  type: SAVE_ITEM_REQUEST
})
export const saveItemSuccess = (item) => ({
  type: SAVE_ITEM_SUCCESS,
  item
})
import { SAVE_ITEM_SUCCESS } from "./actions";
import { saveItemSuccess } from "./actions";

it('should create an action to save a new item in the store', () => {
  const item = {
    title: 'apple'
  }
  const expectedAction = {
    type: SAVE_ITEM_SUCCESS,
    item
  }
  expect(saveItemSuccess(item)).toEqual(expectedAction)
});

Aszinkron action creator

export const save = value => async dispatch => {
  dispatch(saveItemRequest())
  const item = await api.addItem({fruit: value})
  dispatch(saveItemSuccess(item))
}
import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'

import { SAVE_ITEM_SUCCESS, SAVE_ITEM_REQUEST } from "./actions";
import { saveItemSuccess, save } from "./actions";

jest.mock("../api/storage-api", () => ({
  api: {
    async addItem(item) {
      return item
    }
  }
}))

const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)

it('should create SAVE_ITEM_REQUEST and SAVE_ITEM_SUCCESS actions when saving an item', async () => {
  const expectedActions = [
    { type: SAVE_ITEM_REQUEST },
    { type: SAVE_ITEM_SUCCESS, item: {fruit: 'apple'} }
  ]
  const store = mockStore({})

  await store.dispatch(save('apple'))

  expect(store.getActions()).toEqual(expectedActions)
});

Reducer

Tiszta függvény

it('should handle ADD_TODO', () => {
  expect(
    reducer([], {
      type: types.ADD_TODO,
      text: 'Run the tests'
    })
  ).toEqual([
    {
      text: 'Run the tests',
      completed: false,
      id: 0
    }
  ])

  expect(
    reducer(
      [
        {
          text: 'Use Redux',
          completed: false,
          id: 0
        }
      ],
      {
        type: types.ADD_TODO,
        text: 'Run the tests'
      }
    )
  ).toEqual([
    {
      text: 'Run the tests',
      completed: false,
      id: 1
    },
    {
      text: 'Use Redux',
      completed: false,
      id: 0
    }
  ])
})

React komponensek egységtesztelése

  • Smoke test
  • Shallow rendering
  • DOM rendering
  • Snapshot testing
  • Eszközök
    • jsdom
    • React Testing Library
    • Enzyme

Smoke teszt

Nincs hiba a renderelés során

it('renders without crashing', () => {
  const div = document.createElement('div');
  ReactDOM.render(<App />, div);
});

Shallow rendering

  • React belső ábrázolásának tesztelése
  • Gyerekkomponensek nélküli tesztelés
  • Enzyme
import React from 'react';
import { shallow } from 'enzyme';
import App from './App';
it('renders welcome message', () => {
  const wrapper = shallow(<App />);
  const welcome = <h2>Welcome to React</h2>;
  expect(wrapper.contains(welcome)).toEqual(true);
});

DOM rendering

import React from "react";

export default function Hello(props) {
  if (props.name) {
    return <h1>Hello, {props.name}!</h1>;
  } else {
    return <span>Hey, stranger</span>;
  }
}

DOM rendering

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";

import Hello from "./hello";

let container = null;
beforeEach(() => {
  // setup a DOM element as a render target
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // cleanup on exiting
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("renders with or without a name", () => {
  act(() => {
    render(<Hello />, container);
  });
  expect(container.textContent).toBe("Hey, stranger");

  act(() => {
    render(<Hello name="Jenny" />, container);
  });
  expect(container.textContent).toBe("Hello, Jenny!");

  act(() => {
    render(<Hello name="Margaret" />, container);
  });
  expect(container.textContent).toBe("Hello, Margaret!");
});

fetch mock

export default function User(props) {
  const [user, setUser] = useState(null);
  async function fetchUserData(id) {
    const response = await fetch("/" + id);
    setUser(await response.json());
  }
  useEffect(() => {
    fetchUserData(props.id);
  }, [props.id]);
  // ...
}

fetch mock

it("renders user data", async () => {
  const fakeUser = {
    name: "Joni Baez",
    age: "32",
    address: "123, Charming Avenue"
  };
  jest.spyOn(global, "fetch").mockImplementation(() =>
    Promise.resolve({
      json: () => Promise.resolve(fakeUser)
    })
  );

  await act(async () => {
    render(<User id="123" />, container);
  });

  expect(container.querySelector("summary").textContent).toBe(fakeUser.name);
  expect(container.querySelector("strong").textContent).toBe(fakeUser.age);
  expect(container.textContent).toContain(fakeUser.address);

  global.fetch.mockRestore();
});

fetch mock

fetch-mock függvénykönyvtár

import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
import * as actions from '../../actions/TodoActions'
import * as types from '../../constants/ActionTypes'
import fetchMock from 'fetch-mock'
import expect from 'expect' // You can use any testing library

const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)

describe('async actions', () => {
  afterEach(() => {
    fetchMock.restore()
  })

  it('creates FETCH_TODOS_SUCCESS when fetching todos has been done', () => {
    fetchMock.getOnce('/todos', {
      body: { todos: ['do something'] },
      headers: { 'content-type': 'application/json' }
    })

    const expectedActions = [
      { type: types.FETCH_TODOS_REQUEST },
      { type: types.FETCH_TODOS_SUCCESS, body: { todos: ['do something'] } }
    ]
    const store = mockStore({ todos: [] })

    return store.dispatch(actions.fetchTodos()).then(() => {
      // return of async actions
      expect(store.getActions()).toEqual(expectedActions)
    })
  })
})

Gyerekmodul mockolása

jest.mock("./map", () => {
  return function DummyMap(props) {
    return (
      <div data-testid="map">
        {props.center.lat}:{props.center.long}
      </div>
    );
  };
});

// ...

// Contact renders Map as a child
it("should render contact information", () => {
  const center = { lat: 0, long: 0 };
  act(() => {
    render(
      <Contact
        name="Joni Baez"
        email="test@example.com"
        site="http://test.com"
        center={center}
      />,
      container
    );
  });
});

Események

it("changes value when clicked", () => {
  const onChange = jest.fn();
  act(() => {
    render(<Toggle onChange={onChange} />, container);
  });

  const button = document.querySelector("[data-testid=toggle]");
  expect(button.innerHTML).toBe("Turn on");

  act(() => {
    button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  });

  expect(onChange).toHaveBeenCalledTimes(1);
  expect(button.innerHTML).toBe("Turn off");

  act(() => {
    for (let i = 0; i < 5; i++) {
      button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
    }
  });

  expect(onChange).toHaveBeenCalledTimes(6);
  expect(button.innerHTML).toBe("Turn on");
});

Snapshot testing

it("should render a greeting", () => {
  act(() => {
    render(<Hello />, container);
  });

  expect(
    container.innerHTML
  ).toMatchSnapshot();

  act(() => {
    render(<Hello name="Jenny" />, container);
  });

  expect(
    container.innerHTML
  ).toMatchSnapshot();

  act(() => {
    render(<Hello name="Margaret" />, container);
  });

  expect(
    container.innerHTML
  ).toMatchSnapshot();
});
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render a greeting 1`] = `"<span>Hey, stranger</span>"`;

exports[`should render a greeting 2`] = `"<h1>Hello, Jenny!</h1>"`;

exports[`should render a greeting 3`] = `"<h1>Hello, Margaret!</h1>"`;

React Testing Library

// __tests__/fetch.test.js
import React from 'react'
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import axiosMock from 'axios'
import Fetch from '../fetch'

jest.mock('axios')

test('loads and displays greeting', async () => {
  const url = '/greeting'
  render(<Fetch url={url} />)

  axiosMock.get.mockResolvedValueOnce({
    data: { greeting: 'hello there' },
  })

  fireEvent.click(screen.getByText('Load Greeting'))

  await waitFor(() => screen.getByRole('heading'))

  expect(axiosMock.get).toHaveBeenCalledTimes(1)
  expect(axiosMock.get).toHaveBeenCalledWith(url)
  expect(screen.getByRole('heading')).toHaveTextContent('hello there')
  expect(screen.getByRole('button')).toHaveAttribute('disabled')
})

Storybook

UI komponensek izolált megfigyelése

Visual testing

Funkcionális tesztelés

Eszközök

Cypress

Cypress

describe('My First Test', () => {
  it('Gets, types and asserts', () => {
    cy.visit('https://example.cypress.io')

    cy.contains('type').click()

    // Should be on a new URL which includes '/commands/actions'
    cy.url().should('include', '/commands/actions')

    // Get an input, type into it and verify that the value has been updated
    cy.get('.action-email')
      .type('fake@email.com')
      .should('have.value', 'fake@email.com')
  })
})

Végszó

  • Tesztelés
  • Érdemes vele foglalkozni
  • Egyre jobb eszközök vannak