Testing for a Web application project 🔎

Hero image for Testing for a Web application project 🔎

Test Types

For Web application projects, three types of tests are implemented: Unit Tests, Integration Tests, and UI Tests.

Unit Tests

Unit testing is a way of testing a unit, the smallest piece of code of an application. The purpose is to validate that each unit of the application code performs as expected. Unit testing is done during the development of an application by the developers.

Generally, write unit tests for the following modules:

  • models
  • services
  • forms
  • serializers
  • decorators
  • presenters
  • helpers

Integration Tests

Integration testing is a way of testing to ensure that the different components of the application work well together, without the overhead of the actual app environment (e.g. The browser). These tests should assert at the request/response level: status code, headers, body. They are helpful to test permissions, redirections, what view is rendered, etc.

Write integration tests for controllers (including Web and API controllers) and third-party services (e.g. Stripe API, Twilio API).

UI Tests

UI testing is a way of testing an application flow from start to end. UI testing aims to simulate the real user scenario and validate the system under test and its components for integration and data integrity.

Write UI tests for every application screen that users can visit.

Test Coverage

Test coverage is a measurement that helps developers to determine if they are testing everything they are supposed to. It allows checking the quality of application testing by finding areas that are not currently covered. Test coverage helps to develop tests in those areas, increasing the overall quality of the application test suite.

Web Testing Pyramid

Unit tests

Maintain 100 percent test coverage for unit testing. Unit tests are small and isolated, and they are faster than integration tests and UI tests.

Integration tests

Maintain 100 percent test coverage for integration testing also. Integration tests are faster than UI tests but slower than Unit tests.

UI tests

Write UI tests for all features, as well as verify every application screen correctness. UI tests are slower than unit tests and integration tests. That is why it is preferred to restrict writing UI test cases for happy paths only.

Testing Strategy

General

  • For each pull request change, the tests must be added to that same pull request.
  • Treat test code as production code. This mindset will improve the readability, stability, and maintainability of the test suite.

Fixtures

  • Test data (e.g. New test user record) must be cleaned up from the test database after the test run completes.
// Bad
var _ = Describe("User", func() {
    Describe("#CreateUser", func() {
        It("creates a user", func() {
            user, err := models.CreateUser()
            if err != nil {
                Fail("Creating user failed: " + err.Error())
            }

            Expect(user).ToNot(BeNil())
        })
    })
})

// Good
var _ = Describe("User", func() {
    Describe("#CreateUser", func() {
        It("creates a user", func() {
            user, err := models.CreateUser()
            if err != nil {
                Fail("Creating user failed: " + err.Error())
            }

            Expect(user).ToNot(BeNil())
        })
    })

    AfterEach(func() {
        TruncateTables("users")
    })
})
  • Prefer to use a fake data generator (e.g. Faker) for generating test data.
# Bad
Fabricator(:customer) do
  last_name { 'John' }
  first_name { 'Doe' }
  email { '[email protected]@.com' }
end

# Good
require 'faker' 

Fabricator(:customer) do
  last_name { FFaker::Name.last_name }
  first_name { FFaker::Name.first_name }
  email { FFaker::Internet.email }
end

Optimizing

  • Create only necessary test data, e.g. If the pagination per page size is 10, then create only 11 test records to test it.
# Bad
it 'returns 10 customers' do
  Fabricate.times(30, :customer)

  sign_in_user

  get :index

  expect(assigns(:customers).size).to eq(10)
end 

# Good
it 'returns 10 customers' do
  Fabricate.times(11, :customer)

  sign_in_user

  get :index

  expect(assigns(:customers).size).to eq(10)
end 
  • Prefer to use non persisted test objects to speed up tests when possible.
# Bad
it 'returns the amount in currency format' do
  transaction = Fabricate(:transaction, amount: '100.00')

  expect(transaction.formatted_amount).to eq('฿ 100.00')
end

# Good 
it 'returns the amount in currency format' do
  transaction = Fabricate.build(:transaction, amount: '100.00')

  expect(transaction.formatted_amount).to eq('฿ 100.00')
end

DRY-ing

  • Create testing helper methods/functions to reduce repetition across multiple tests.
# Bad
describe 'GET #index' do
  it 'returns http success' do
    admin = Fabricate(:admin)
    request.env['devise.mapping'] = Devise.mappings[:user]
    sign_in(admin)

    get :index

    expect(response).to have_http_status(:success)
  end
end

# Good
describe 'GET #index' do
  it 'returns http success' do
    sign_in_as_admin

    get :index

    expect(response).to have_http_status(:success)
  end
end

# in the `/spec/support/devise_helpers.rb`
def sign_in_as_admin
  admin = Fabricate(:admin)
  request.env['devise.mapping'] = Devise.mappings[:user]
  sign_in(admin)
end
  • Each test must verify a single-use case.
# Bad
it 'renders index view' do
  sign_in_as_admin

  get :index

  expect(response).to render_template :index
  expect(response).to have_http_status :ok
end

# Good
it 'renders index view' do
  sign_in_as_admin

  get :index

  expect(response).to render_template :index
end
 
it 'returns 200 status' do
  sign_in_as_admin

  get :index

  expect(response).to have_http_status :ok
end 

Stubbing and Mocking

  • All network requests must be mocked/stubbed.
# Bad
it 'sends an SMS to the customer' do
  customer = Fabricate(:customer)
     
  # `service.call` makes a network request
  service = SMSService.new(customer)
     
  expect(service.call).to eq(true)
end

# Good
it 'sends an SMS to the customer' do
  customer = Fabricate(:customer)
      
  # `service.call` makes a network request
  service = SMSService.new(customer)

  # Mocks `call` method to prevent network request
  allow(service).to receive(:call).and_return(true)
 
  expect(service.call).to eq(true)
end
  • Prefer to use an HTTP request/response recorder (e.g. VCR) whenever possible over manual mocking/stubbing when testing external network requests. An HTTP request/response recorder records the request/response for the first time a test is executed (stored as files) and then re-uses these recordings for future test runs. So the tests can be executed with stubbed external network requests with real requests/responses.
# Good
it 'sends an SMS to the customer' do
  customer = Fabricate(:customer)
      
  # `service.call` makes a network request
  service = SMSService.new(customer)

  # Mocks `call` method to prevent network request
  allow(service).to receive(:call).and_return(true)
 
  expect(service.call).to eq(true)
end

# Preferred
it 'sends an SMS to the customer' do
  customer = Fabricate(:customer)
      
  # `service.call` makes a network request
  service = SMSService.new(customer)

  # Record the response when this test run for the first time and re-use the response when test runs later
  VCR.use_cassette('sms/send_sms_success') do
    expect(service.call).to eq(true)
  end
end

API

  • Prefer using a JSON Schema matcher (e.g. json_matchers) to validate JSON API responses.
# Bad
it 'responds with user' do
  user = Fabricate(:user)

  authenticate_with_doorkeeper(user)

  get api_v1_me_path

  response = JSON.parse(response.body)

  expect(response.dig(:data, :attributes, :email)).to eq(user.email)
  expect(response.dig(:data, :attributes, :username)).to eq(user.username)
end

# Good
it 'responds with user' do
  user = Fabricate(:user)

  authenticate_with_doorkeeper(user)

  get api_v1_me_path

  response = JSON.parse(response.body)

  expect(response).to match_response_schema('v1/users/user', strict: true)
end

Tools - Libraries

Prefer to use the following libraries for testing:

Ruby

  • RSpec is a domain-specific language testing tool to test Ruby code. Check our Rspec code conventions.
  • Capybara is an acceptance testing framework for Ruby Web applications.
  • Fabrication is a Ruby object factory generator.
  • VCR is an HTTP request/response recorder for Ruby.
  • Faker is a fake data generator for Ruby.

Elixir

  • ExUnit is a built-in unit testing framework for Elixir. Check our ExUnit code conventions.
  • Wallaby is an acceptance testing framework for Elixir Web applications.
  • ExMachina is a test data factory generator for Elixir.
  • ExVCR is an HTTP request/response recorder for Elixir.
  • Faker is a fake data generator for Elixir.
  • Mimic is a mocking library for Elixir.

GO

  • Ginkgo is a BDD testing framework for Go.
  • Gomega is a matcher library for Go.
  • go-vcr is an HTTP request/response recorder for Go.
  • Faker is a struct data fake generator for Go.

JavaScript

  • Jest is a testing framework for JavaScript.
  • Cypress is a UI testing framework for JavaScript Web applications. Check our Cypress code conventions.