Software Testing Overview — Unit Testing and Testing Pyramid

Hanwen Zhang
6 min readJun 16, 2022

What is a good amount of code coverage when it comes to testing? That is a million-dollar question.

First, let’s go over the differences between unit testing, integration testing, and functional testing.

  • Unit testing: test a small chunk of code, like a method
  • Integration testing: test a certain workflow all working together
  • Functional testing: manual, spot check, deploy your code in staging, testing manually that all features are working per requirements

Unit Testing

Unit testing is important, it is testing a file completely separate from the rest, even if you have dependencies, they can be mocked, and usually be written by the developers of the application code.

Basically, the bottom stack means tests should be isolated, and usually, they run faster, so most of the testing should be done on Unit Test (that's why the piece is bigger) and as you move to the top, things should be tested at a smaller level, because they require more integration with dependencies (databases, external APIs, etc) and for that reason, they are slower to run.

So, we should have integration tests, but we don't have to do integration tests for everything… because we don't need to actually have something created on the database to test your stuff, we can just mock it and that way saves some time and test properly what would happen. Of course, that is the assumption that the external calls call your files have been properly tested. If they have been unit tested properly, then we don't need to make that call, because it has been tested to be working fine, and that's why we mock it instead of letting the call go through.

Any test is composed of 3 parts:

  1. Prerequisites that are needed
  2. The expectation of what the result should be
  3. Trigger to make the call that will be tested

A rough example using RSpec — A testing library for Ruby

subject { described_class.process(data: data) }
let(:id) { 123456 }

describe '#process' do
context 'when everything is going well' do
before
allow(ExampleClass).to receive(:process).with(id: id).and_return(true) # Mocking here that the method return true
end

it 'returns true' do
expect(ClassMethod).to call(:process).once # Expectation that it should call the service once
subject // Trigger
end
end
end

Mock things are with the “allow” keyword, while the “expect” keyword is for what you are going to check.

The Practical Test Pyramid

https://martinfowler.com/articles/practical-test-pyramid.html#TheTestPyramid

Continuous delivery, a practice where you automatically ensure that your software can be released into production at any time, can help you deliver your software faster without sacrificing its quality.

With continuous delivery, you use a build pipeline to automatically test your software and deploy it to your testing and production environments.

Automating everything — from build to tests, deployment, and infrastructure.

Test Pyramid

Testing Three Layers:

  1. Unit Tests
  2. Service Tests
  3. User Interface Tests

Testing Rules:

  1. Write tests with different granularity
  2. The more high-level you get the fewer tests you should have

Write lots of small and fast unit tests. Write some more coarse-grained tests and very few high-level tests that test your application from end to end.

Unit Tests

The foundation of your test. Your unit tests make sure that a certain unit (your subject under test) of your codebase works as intended, and a unit test class should at least test the public interface of the class.

Simply stick to the one test class per production class rule of thumb and you’re off to a good start.

Don’t reflect your internal code structure within your unit tests. Test for observable behavior instead. Think about:

if I enter values x and y, will the result be z?

Unit Test When Refactor Codes

With refactoring, you don’t change what your software does, you change how it does it. It improves the structure of code without changing its external behavior.

If the refactoring does not change the public interface, then you leave the unit tests as is and ensure after refactoring they all pass.

If the refactoring does change the public interface, then the tests should be rewritten first. Refactor until the new tests pass.

  1. identifying at what level that behavior will remain the same
  2. that the interface driving that behavior will remain the same
  3. start putting in tests at that point.

Test Structure

  1. Set up the test data
  2. Call your method under the test
  3. Assert that the expected results are returned

“Arrange, Act, Assert”“given”, “when”, “then” triad, where given reflects the setup, when the method call, and then the assertion part.

Integration Tests

All non-trivial applications will integrate with some other parts (databases, filesystems, network calls to other applications). When writing unit tests these are usually the parts you leave out in order to come up with better isolation and faster tests. Integration Tests test the integration of your application with all the parts that live outside of your application.

  1. start a database
  2. connect your application to the database
  3. trigger a function within your code that writes data to the database
  4. check that the expected data has been written to the database by reading the data from the database
  1. start your application
  2. start an instance of the separate service (or a test double with the same interface)
  3. trigger a function within your code that reads from the separate service’s API
  4. check that your application can parse the response correctly

Contract Tests

Microservices — Splitting your system into many small services often means that these services need to communicate with each other via certain interfaces.

  • REST and JSON via HTTPS
  • RPC using something like gRPC
  • building an event-driven architecture using queues

For each interface, there are two parties involved: the provider and the consumer:

  • The provider serves data to consumers.
  • The consumer processes data obtained from a provider.

In a REST world, a provider builds a REST API with all required endpoints; a consumer makes calls to this REST API to fetch data or trigger changes in the other service.

In an asynchronous, event-driven world, a provider (often rather called a publisher) publishes data to a queue; a consumer (often called a subscriber) subscribes to these queues and reads and processes data.

UI Tests

UI tests test that the user interface of your application works correctly. User input should trigger the right actions, data should be presented to the user, and the UI state should change as expected. Test for usability and a “looks good” factor you leave the realms of automated testing.

End-to-End Tests

End-to-End Tests test your deployed application via its user interface which requires a lot of maintenance and running pretty slowly.

Quality Assurance

QA is an important role in the world of software development, they are specialized in designing case scenarios of workflows, testing edge cases, and handling errors. QAs are also writing automatizing test cases that help to automatize test case scenarios which improve and simplify development workflows. It is different from writing unit tests, these test cases are more about usability cases on the application itself.

--

--

Hanwen Zhang

Full-Stack Software Engineer at a Healthcare Tech Company | Document My Coding Journey | Improve My Knowledge | Share Coding Concepts in a Simple Way