ITea Talks with Martin Ivanov: Cracking the Code of Unit Testing: From Philosophies to Real-World Challenges

ITea Talks with Martin Ivanov: Cracking the Code of Unit Testing: From Philosophies to Real-World Challenges

In today’s rapidly evolving software development landscape, testing is more critical than ever. Among the many layers of testing, unit testing stands out for its role in verifying the smallest functional components of an application. By catching bugs early, unit tests contribute to the overall reliability and maintainability of software systems, especially in complex applications.

How do modern software development teams approach testing? Where does unit testing fit into the test hierarchy? What are the philosophies behind unit testing? Are there unit testing challenges unique for frontend development? Today, we answer these questions with Martin Ivanov - Senior Software Engineer at adesso Bulgaria .

?

Source: Martin Ivanov, adesso Bulgaria

Hello, Martin! Can you provide a brief overview of the types of software tests that exist and where unit testing fits in?

?Hello! Testing is like building a safety net for your code. There are many types of tests, each with a unique purpose:

  • Unit Tests: Verify the correctness of individual functions or methods. They are the foundation of the testing pyramid, focusing on isolated components. Unit tests are fast and help catch bugs early in the development cycle.
  • Integration Tests: Test the interaction between different components or services, ensuring they work together as expected.
  • Functional Tests: Assess specific application features to ensure they meet business requirements. These tests are broader and typically involve end-to-end scenarios.
  • End-to-End (E2E) Tests: Simulate real-world user interactions with the application. These tests are comprehensive but slower and harder to maintain.
  • Performance Tests: Evaluate how the application performs under load or stress.
  • Security Tests: Ensure the application is resistant to vulnerabilities like SQL injection or cross-site scripting.
  • Regression Tests: Ensure that new code changes do not adversely affect existing functionality. Regression tests can be automated and are often run after every code change to quickly identify any unintended side effects. They can include unit tests, integration tests, and end-to-end tests.
  • Acceptance Tests: Validate the entire system's functionality against the requirements and specifications. These tests are often performed by QA teams and ensure that the software meets the business needs and user expectations.
  • Smoke Tests: A subset of tests run to ensure that the most critical functions of the application work. These tests are typically run after a new build or deployment to verify that the application is stable enough for further testing.
  • Contract Tests: Ensure that APIs or interfaces between services adhere to predefined contracts. This is especially valuable in microservices architectures to avoid breaking changes.
  • UI/UX Tests: Validate the user interface and experience, including visual consistency and responsiveness.
  • Accessibility Tests: Verify that your tests cover accessibility features so that user interactions are accessible to all users, including those with disabilities.

A balanced test suite typically incorporates multiple test types. For backend and frontend alike, unit tests are the foundation everything else is built on.

Could you explain the philosophies behind unit testing and their practical implications?

?Unit testing revolves around ensuring that the smallest units of functionality in an application behave as expected. Thus, they should be designed plain and simple, not much logic and few to no abstractions at all. If a unit test is perceived as a burden rather than as a friendly assistant, then something is wrong with the test design or implementation.

Over time, two distinct schools of thought with respect to unit testing have emerged:

  • Classic Approach: Emphasizes testing the behaviour of units in isolation without focusing on their implementation details. Dependencies are replaced with real objects or test doubles such as fakes or stubs if it is impractical to use the real thing. Hence, the test outcomes reflect actual scenarios. This style of testing uses state verification: which means that we determine whether the exercised method worked correctly by examining the state of the unit under test and its collaborators after the method was exercised.

- Benefits: Tests focus on the result, not the implementation, which fosters robust code refactoring.

- Drawbacks: Can be harder to isolate the unit under test, leading to more complex test setups. May also result in slower tests if real dependencies are used.

  • Mockist Approach: Relies on using mocks to replace dependencies, focusing on the interaction between components. The primary goal is to ensure that the unit calls its dependencies in the expected way. Mocks use behaviour verification, where correctness is determined by verifying the interactions between the unit and its dependencies, e.g. by checking if the respective methods were called correctly.

-??Benefits: Encourages strict adherence to component boundaries.

-? Drawbacks: Tends to overemphasise implementation details, making refactoring more challenging.

?

You mention stubs, fakes, mocks. Can you explain more about the different types of test doubles used in unit testing?

?Sure, in unit testing, the knack is to isolate the unit under test from its dependencies to ensure that the test results are reliable and focused solely on the unit's behaviour. This is where test doubles come into play: they are substitutes for real objects in a testing environment, allowing developers to isolate the unit under test and ensure reliable results. There are several types of test doubles:

  • Dummies: Placeholder objects passed around but never actually used.
  • Stubs: ?Provide predefined responses and are limited to specific interactions.
  • Spies: Stubs that also record interaction details, such as method calls and arguments, making them suitable for behaviour verification.
  • Fakes: Closer-to-real test doubles that have simplified logic and can handle a broader range of interactions.
  • Mocks: Test doubles that are pre-programmed with expectations about the interactions that should occur. Mocks are used for behaviour verification, ensuring that the unit under test calls its dependencies in the expected way.

?Of these kinds of doubles, only mocks insist upon behaviour verification. The other doubles can, and usually do, use state verification.

?

Which objects are usually replaced by test doubles?

The objects that are typically replaced by test doubles include:

  • External Services: APIs, web services, and other external systems that the unit under test interacts with.
  • Databases: Replacing real databases with in-memory databases or other fakes to avoid the overhead of database operations.
  • File Systems: Replacing file system interactions with in-memory file systems or mocks.
  • Network Connections: Replacing network calls with mocks or stubs to avoid network latency and failures.
  • Complex Collaborators: Objects that have complex behaviour or are difficult to set up and control in a test environment.
  • User Interfaces: Replacing UI components with simpler test doubles to focus on the logic being tested.

?

As the focus of your expertise is frontend development, can you illustrate these concepts with frontend test examples?

Yes, here are two examples of unit tests using Jest with TypeScript and React:

?

1. Classic Approach:

import { render, screen } from '@testing-library/react'

import { http, HttpResponse } from 'msw'

import { setupServer } from 'msw/node'

?

import UserList from './UserList'

?

const server = setupServer(

??? http.get('/api/users', () => {

??????? return new HttpResponse.json([{ id: 1, name: 'John Doe' }])

??? })

)

?

beforeAll(() => server.listen());

afterEach(() => server.resetHandlers());

afterAll(() => server.close());

?

test('renders a list of users', async () => {

??? render(<UserList />)

??? expect(await screen.findByText(/John Doe/i)).toBeInTheDocument()

})

?

Here, while the REST API call is mocked with the Mock Service Worker library, the function fetchUserData that makes the call is untouched. This ensures the test will catch bugs in that function and remain unaffected by changes to the implementation of data fetching.

?

2. Mockist Approach:

import { render, screen, fireEvent } from '@testing-library/react'

?

import { fetchUserData } from './api'

import UserProfile from './UserProfile'

?

jest.mock('./api')

const mockFetchUserData = fetchUserData as jest.Mock

?

mockFetchUserData.mockResolvedValue({ id: 1, name: 'Jane Doe' })

?

test('fetches and displays user data', async () => {

??? render(<UserProfile />)

??? expect(mockFetchUserData).toHaveBeenCalledTimes(1)

??? expect(await screen.findByText(/Jane Doe/i)).toBeInTheDocument()

})

?

Here, the function fetchUserData that makes the REST API call is mocked. Thus, if a bug occurs in that function, it will not be caught. If the implementation of fetching the user data is changed, e.g. fetchUserData is renamed to fetchData, the test will have to be adjusted; the same would be needed, if the user data structure is changed, e.g. id is renamed to userId.

?

In your opinion, what’s the biggest challenge in unit testing, particularly for frontend development?

Frontend unit testing has its own quirks. State management is probably the toughest nut to crack.? Frontend components often rely on a shared global state (e.g., React Context, Zustand, etc.).? Testing a component in isolation can require setting up mock states, which may become cumbersome if the store structure is complex. Moreover, mocking the global state, inadvertently leads to testing implementation details, which beats the purpose of unit testing. To address this, the following measures can be taken:

  • Decouple Components from State: Design components to receive state and handlers as props rather than accessing global state directly. This promotes reusability and simplifies testing.
  • Encapsulate State Logic in Custom Hooks or Higher-Order Components: By abstracting state logic into custom hooks or dedicated wrapper components, components under test remain focused on presentation. Hooks can be tested independently, and their behaviour mocked in component tests.
  • Behaviour-Driven Testing: Write tests that validate user-visible outcomes rather than internal state changes. This approach aligns tests with real-world usage scenarios.
  • Integration Tests for Complex State: Use integration tests to verify interactions between components and state management libraries, reserving unit tests for isolated functionality.

?

Which of two principal approaches to unit testing do you recommend, and why?

I prefer the classic approach because:

  • It avoids testing implementation details, making tests more resilient to changes.
  • Tests reflect real-world scenarios, improving confidence in the codebase.
  • It aligns with the principles of clean code and maintainable architecture.

?

While both the classic and mockist approaches have their merits, the classic approach stands out for its focus on behaviour over implementation, making it particularly valuable in frontend development. However, it’s important to note that these approaches are not mutually exclusive. While one may generally be preferred, certain situations might objectively call for the other. As a best practice, I recommend adhering to one approach consistently, but remaining flexible and open to employing the other when common sense dictates.

?Unit testing isn’t just about writing tests—it’s about building confidence in your work. A well-designed test suite bridges the gap between code and quality, empowering developers across the stack to deliver reliable, maintainable software.

要查看或添加评论,请登录

adesso Bulgaria的更多文章

社区洞察

其他会员也浏览了