When it comes to developing quality React Native applications, an effective testing strategy is as crucial as the development itself. “To me, legacy code is simply code without tests,” says Michael Feathers in Working Effectively with Legacy Code. There is one difference between Quality Code and Legacy Code, and as Feathers points out, this definition of Legacy Code points directly to the solution. Tests.
Seasoned developers and those new to the field alike should understand the nuances between unit tests, integration tests, and end-to-end tests, which will significantly enhance your application's quality and user experience. Here's a simple guide to help you navigate these testing methodologies within a frontend-only React Native project.
Understanding Different Testing Levels
1. Unit Tests
Unit tests are the foundational level of software testing where you evaluate individual components or functions in isolation. These tests are predominantly focused on logic and data handling of single components without any external dependencies.
Why do they they matter? They help verify that individual elements perform as expected and also facilitate rapid debugging.
You might write several unit tests to verify that a function formats a currency correctly.
test("formats cents to USD correctly", () => {
expect(formatCentsToUSD(100)).toBe("$1.00");
});
Or you might write several unit tests to verify that a custom "HeaderPressable" component renders correctly and reacts to its props.
test("calls onPress", async () => {
let onPressSpy = jest.fn();
render(<HeaderPressable title="Press test!" onPress={onPressSpy} />);
await userEvent.press(screen.getByText("Press test!"));
expect(onPressSpy).toHaveBeenCalledTimes(1);
});
2. Integration Tests
As the name suggests, integration tests examine how multiple components or parts of the application work together. In the context of a frontend-only React Native app, this would typically mean checking if various components communicate and collaborate correctly.
Why do they matter? Integration tests ensure that your app's components coexist harmoniously without errors, which is essential for tasks involving multiple user interactions.
An integration test could simulate a user filling out and submitting a form to see if the app correctly handles data across different components and updates the app's state as expected.
test("uses default values and handles submission", async () => {
// Arrange
renderSettings(id);
// Assert initial state
expect(await screen.findByText("Auto")).toHaveAccessibilityState({
...defaultAccessibilityState,
checked: true,
});
// Act
userEvent.press(screen.getByText("Submit"));
// Assert navigation and storage interaction
await waitFor(() => expect(goBackSpy).toHaveBeenCalledTimes(1));
expect(setItemSpy).toHaveBeenCalledWith(
settingsAsyncStorageKey,
JSON.stringify({ [id]: expectedSettings }),
);
});
3. End-to-End (E2E) Tests
End-to-end tests simulate real-world user scenarios from start to completion. They are invaluable for testing the flow of the entire application, just as a user would experience it.
Why do they matter? They offer a final assurance that the application functions as intended on actual devices or emulators, capturing errors that may not be visible at the unit or integration level.
An E2E test might walk through a user authentication flow, navigating through various screens, modifying their profile, or logging out.
describe.each([
[
"en",
{
sign_in: "Sign In",
username_placeholder: "Username",
password_placeholder: "Password",
successful_login_message: "Welcome!",
},
],
[
"es",
{
sign_in: "Iniciar Sesión",
username_placeholder: "Usuario",
password_placeholder: "Contraseña",
successful_login_message: "¡Bienvenido!",
},
],
])(`App (locale: %s)`, (locale, localeStrings) => {
beforeEach(async () => {
await device.launchApp({
languageAndLocale: { language: locale, locale },
});
});
test("should allow user to sign in", async () => {
// Arrange
let { sign_in, username_placeholder, password_placeholder } =
localeStrings;
// Assert initial state
await expect(element(by.text(sign_in))).toBeVisible();
// Act
await element(by.placeholder(username_placeholder)).typeText(
"danny@tunney.dev",
);
await element(by.placeholder(password_placeholder)).typeText(
"testing123",
);
await element(by.text(sign_in)).tap();
// Assert
await expect(
element(by.text(localeStrings.successful_login_message)),
).toBeVisible();
});
});
Why Each Level Matters
While it might be tempting to skip one or more levels of testing based on resource constraints, each type of test plays a pivotal role in the development lifecycle:
- Unit tests help maintain robustness at the microscopic level, allowing developers to refine and refactor confidently.
- Integration tests bridge the gap between unit tests and E2E tests, ensuring that as you build the application, the components not only work well independently but also in conjunction with one another.
- End-to-End tests are crucial for confirming that the entire application provides a seamless experience to the end user, matching their expectations and fulfilling the intended business logic.
Implementing Your Testing Strategy
To effectively incorporate these tests into your development process:
- Start with unit tests while developing individual components.
- Layer integration tests as you begin linking components together.
- Cap your testing with E2E tests to simulate real-world usage.
By clearly understanding the scope and purpose of each testing level, you can craft a comprehensive testing approach that ensures your React Native frontend app is not only bug-free but also delivers a great user experience.
Remember, each layer of testing brings its own set of benefits to the table, and together, they contribute to a robust, reliable, and enjoyable application.