Building Exceptional Flutter Apps: The Power of Testing and Quality Assurance

Vicky Maulana
15 min readMay 9, 2024

--

Within the software development lifecycle, testing and quality assurance (QA) stand as essential pillars, ensuring applications deliver on their promises of reliability, functionality, and user satisfaction. This exploration will delve into the significance of testing and QA, examining their core principles and the benefits they bring to software development projects.

The Significance of Testing

Testing involves assessing software systems and components to detect defects, validate functionality, and confirm adherence to specified requirements. Below are key reasons highlighting the critical nature of testing:

  • Bug Identification: Testing uncovers software bugs and errors early in the development phase, enabling swift resolution and preventing issues during production.
  • Enhanced Software Quality: Comprehensive testing elevates overall software quality by verifying its functionality, performance, and user experience.
  • User Contentment: Testing ensures the delivery of dependable and intuitive applications, fostering higher user satisfaction and customer loyalty.
  • Risk Reduction: Testing minimizes risks associated with software malfunctions, security vulnerabilities, and compliance lapses, safeguarding businesses’ reputations and financial stability.

Principles of Quality Assurance

Quality assurance (QA) encompasses a range of processes and activities geared towards ensuring software excellence. Below are fundamental principles that underpin quality assurance:

  • Validation of Requirements: QA ensures that software meets specified requirements and aligns with stakeholders’ expectations.
  • Adherence to Processes: QA promotes conformity to established development processes, industry standards, and best practices, fostering consistency and efficiency in software development.
  • Defect Prevention: QA emphasizes proactive measures to prevent defects, employing techniques like code reviews, static analysis, and early testing to reduce the overall effort and cost associated with bug fixing.
  • Continuous Improvement: QA cultivates a culture of continuous improvement, encouraging the identification and implementation of strategies to enhance software quality.

Testing Strategies and Techniques

Various strategies and techniques are employed in testing to validate software functionality. Commonly used approaches include:

  • Unit Testing with Flutter: Unit testing focuses on testing individual functions, methods, or classes in isolation to ensure their correctness. Flutter offers a robust testing framework that allows developers to write and execute unit tests using the flutter_testpackage. Below is an example:
import 'dart:async';

import 'package:nani/core/data/network/dio/dio_client.dart';
import 'package:nani/data/network/constants/endpoints.dart';
import 'package:nani/domain/entity/user/user.dart';
import 'package:nani/domain/usecase/user/update_user_usecase.dart';

class UserApi {
// dio instance
final DioClient _dioClient;

// injecting dio instance
UserApi(this._dioClient);

Future<User> getUser() async {
try {
final res = await _dioClient.dio.get(Endpoints.getUser);
return User.fromJson(res.data['data']);
} catch (e) {
rethrow;
}
}

Future<UpdateUserParams> updateUser(UpdateUserParams params) async {
try {
final res = await _dioClient.dio.put(Endpoints.updateUser, data: {
"name": params.name,
"email": params.email,
"phone": params.phone,
});
return UpdateUserParams.fromJson(res.data['data']);
} catch (e) {
rethrow;
}
}
}


import 'dart:math';

import 'package:dio/dio.dart';
import 'package:mockito/mockito.dart';

import 'package:nani/data/network/apis/auth/login_api.dart';
import 'package:nani/data/network/apis/auth/register_api.dart';
import 'package:nani/data/network/apis/auth/user_api.dart';

import 'package:nani/domain/entity/user/user.dart';
import 'package:nani/domain/usecase/user/login_usecase.dart';
import 'package:nani/domain/usecase/user/register_usecase.dart';
import 'package:nani/domain/usecase/user/update_user_usecase.dart';

import 'package:test/test.dart';

import '../../../../helper/test_helper.mocks.dart';

void main() async {
late MockDioClient mockDioClient;

// Define other necessary variables
Random random = Random();
int randomInt = random.nextInt(1000000);

late RegisterApi registerApi;
late LoginApi loginApi;
late UserApi userApi;
late String accessToken;

Response<dynamic> response;

setUp(() async {
// Initialize the mock DioClient
mockDioClient = MockDioClient();
accessToken = 'ACCESS_TOKEN';

// Mock the behavior of the DioClient's dio getter
when(mockDioClient.dio).thenReturn(Dio()); // Return a mocked Dio instance
registerApi = RegisterApi(mockDioClient);
loginApi = LoginApi(mockDioClient);
userApi = UserApi(mockDioClient);

Response<dynamic> res = await loginApi.login(LoginParams(
email: 'test@gmail.com',
password: 'aaaaaa',
));

accessToken = res.data['data']['token'];
mockDioClient.dio.options.headers['Authorization'] = 'Bearer $accessToken';
});

group('Accounts', () {
var userRegisterCredentials = <String, String>{
"name": "Eugenius Vicky Wijaksara",
"email": "halodek$randomInt@gmail.com",
"phone": "038716482548",
"password": "aaaaaa",
"password_confirm": "aaaaaa"
};

var userLoginCredentials = <String, dynamic>{
'email': 'halodek$randomInt@gmail.com',
'password': 'aaaaaa',
};

test('signs up user', () async {
final RegisterParams registerParams = RegisterParams(
name: userRegisterCredentials['name'] ?? "",
email: userRegisterCredentials['email'] ?? "",
phone: userRegisterCredentials['phone'] ?? "",
password: userRegisterCredentials['password'] ?? "",
confirmPassword: userRegisterCredentials['password_confirm'] ?? "",
);

Map<String, dynamic> registerResponse =
await registerApi.registerUser(registerParams);
expect(registerResponse['user']['name'], 'Eugenius Vicky Wijaksara');
});

test('signs in user and fetches account information', () async {
final LoginParams loginParams = LoginParams(
email: userLoginCredentials['email'] ?? "",
password: userLoginCredentials['password'] ?? "",
);

response = await loginApi.login(loginParams);

expect(response.statusCode, 200);
expect(response.data['message'], 'login successful');

User user = await userApi.getUser();

expect(user.name, 'Test Account');
});

test("update user params", () async {
var userUpdateCredentials = <String, dynamic>{
"name": "Test Account",
"email": "test@gmail.com",
"phone": "038716482548"
};

final UpdateUserParams updateUserParams = UpdateUserParams(
name: userUpdateCredentials['name'] ?? "",
email: userUpdateCredentials['email'] ?? "",
phone: userUpdateCredentials['phone'] ?? "",
);

UpdateUserParams updateResult =
await userApi.updateUser(updateUserParams);

expect(updateResult.name, userUpdateCredentials['name']);
});
});
}

In this example, we establish a straightforward API class featuring an User Api method. The corresponding unit test verifies whether the getUser() and updateUser()yields the expected outcome and behavior.

  • Integration Testing with Flutter: Integration testing validates the interaction among various components to ensure smooth collaboration. Flutter’s flutter_test package enables developers to write integration tests encompassing widget testing, interactions, and navigation flows. Below is an example:
// app.dart
import 'package:flutter/material.dart';

class App extends StatelessWidget {
final String title;

const App({required this.title});

@override
Widget build(BuildContext context) {
return MaterialApp(
title: title,
home: Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Text('Welcome to $title'),
),
),
);
}
}

// integration_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/app.dart';

void main() {
testWidgets('App Renders Correctly', (WidgetTester tester) async {
await tester.pumpWidget(App(title: 'My App'));

expect(find.text('Welcome to My App'), findsOneWidget);
expect(find.text('Unknown Title'), findsNothing);
});
}

In this example, we create an App widget that displays a title. The integration test uses Flutter's testWidgets method to ensure that the app renders correctly by finding specific text widgets.

  • End-to-End (E2E) Testing with Flutter: E2E testing validates the complete user journey through the app, simulating real user interactions. Flutter provides the flutter_driver package to write and execute E2E tests using the Dart programming language. Here's an example:
// main.dart
import 'package:flutter/material.dart';

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('My App'),
),
body: Center(
child: ElevatedButton(
child: const Text('Press Me'),
onPressed: () {},
),
),
),
);
}
}

// main_e2e_test.dart
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
group('App E2E Test', () {
FlutterDriver driver;

setUpAll(() async {
driver = await FlutterDriver.connect();
});

tearDownAll(() async {
if (driver != null) {
driver.close();
}
});

test('Button Press Test', () async {
final buttonFinder = find.byType('ElevatedButton');
await driver.waitFor(buttonFinder);
await driver.tap(buttonFinder);

final textFinder = find.text('Button Pressed');
await driver.waitFor(textFinder);
expect(await driver.getText(textFinder), 'Button Pressed');
});
});
}

Test Automation and Tooling

Test automation plays a crucial role in enhancing the efficiency and effectiveness of testing processes. Automation frameworks and tools contribute to streamlining testing procedures, expanding test coverage, and accelerating feedback loops. Prominent testing tools include:

  • Selenium: An extensively utilized open-source tool for web application testing, supporting multiple programming languages and browsers.
  • JUnit: A unit testing framework tailored for Java-based applications, offering assertions, test fixtures, and test runners to facilitate efficient testing.
  • TestNG: Another Java testing framework that extends JUnit by incorporating additional features such as test configuration, dependency management, and parallel test execution.
  • Cucumber: A behavior-driven development (BDD) tool that supports test automation through a plain-text format for defining test scenarios and steps.
  • Postman: A tool specialized in testing APIs and web services, equipped with capabilities to dispatch requests, analyze responses, and automate API testing workflows.

Flutter Static Analyzer

The Dart analyzer:

Think of the Dart analyzer as a helpful assistant for your Dart code. It’s a tool that examines your code without actually running it (like a spell checker for writing). It’s very useful because it:

  • Finds errors early: It spots mistakes in your code even before you try to run it, saving you time later. For example, it might point out a missing semicolon or a typo in a variable name.
  • Checks your style: It makes sure your code follows good practices, making it easier for you and others to read. It might suggest adding comments or using more descriptive variable names.
  • Gives you feedback: It offers suggestions on how to improve your code’s structure and efficiency. For instance, it might recommend replacing a repetitive block of code with a loop.

To use the Dart analyzer, you simply type dart analyze lib in your command line (where you normally type commands). This will thoroughly examine your Dart code. You can run this within your Flutter project as well.

Example Output from Dart Analyzer:

Analyzing lib...
info • lib/main.dart:6:3 • avoid_print
Avoid using print() for logging. Use the logging package instead.
warning • lib/utils.dart:12:5 • unused_local_variable
The value of the local variable 'temp' isn't used.
1 warning and 1 info found.

In this example, the analyzer found:

  • An informational message about avoiding print() statements and suggests using a logging package instead.
  • A warning about an unused variable called temp.

The Flutter analyzer:

Now, the Flutter analyzer is like the Dart analyzer, but with extra superpowers for Flutter projects. It does everything the Dart analyzer does, plus it checks for issues specific to Flutter apps.

  • Checks Flutter rules: It ensures your Flutter code follows the recommended guidelines for building Flutter applications. It might flag the use of deprecated widgets or incorrect layout practices.
  • Catches Flutter-specific problems: It detects errors that might only appear in Flutter apps. For example, it might alert you to a widget that’s trying to access data before it’s loaded.
  • Helps you write better Flutter code: It guides you towards creating clean, well-organized Flutter code that adheres to Flutter’s best practices.

To use the Flutter analyzer, type flutter analyze in your command line within your Flutter project.

Controlling the analyzer:

Both the Dart and Flutter analyzers can be adjusted to fit your preferences. You can do this by creating a file called analysis_options.yaml. In this file, you can:

  • Choose which rules to follow: You can pick the specific guidelines you want your code to adhere to.
  • Ignore certain files: You can tell the analyzer to skip over particular files or folders that you don’t want checked.

This customization allows you to tailor the analysis to your project’s unique needs.

Quality Assurance and the Flutter Analyzers

In Flutter development, ensuring the quality of your code is crucial. The Dart and Flutter analyzers are like your very own quality control team, working tirelessly in the background to keep your code in top shape. They automatically review your code, similar to how a meticulous editor would, looking for any problems, security weaknesses, or places where your code strays from recommended practices.

By making these analyzers a regular part of your development process, you can uncover and fix errors early on, preventing them from snowballing into larger, more complex issues down the line. This proactive approach is not only a time and money saver but also leads to a better-structured and easier-to-maintain Flutter project.

Think of the analyzers as guardians of consistency. They encourage your whole development team to follow the same set of style rules, making your code look and feel uniform. This consistency makes it much simpler for everyone to grasp what the code is doing and to make changes confidently. It’s like everyone speaking the same language, which leads to smoother communication and a more productive team overall.

Securing Secrets Variable

“Secrets” are like the crown jewels of your project. They’re the sensitive information that your app needs to function, but that you definitely don’t want falling into the wrong hands. These secrets might include:

  • API keys: Your golden tickets to access external services.
  • Database credentials: The usernames and passwords that unlock your data vaults.
  • Authentication tokens: The keys to your users’ private kingdoms.
  • Encryption keys: The codes that protect your app’s communication.

If these secrets are exposed, it’s not just embarrassing — it can lead to serious security breaches, data leaks, and even financial losses. So, how do you keep your Flutter secrets safe?

The Never-Do List: Hardcoding Secrets

The most important rule: never hardcode secrets directly into your Flutter code. That’s like leaving your house keys under the doormat — anyone can find them. Even if you think your code is private, it’s surprisingly easy for determined attackers to dig it up.

The Flutter Way: Environment Variables and Secure Storage

Here’s a safer approach:

  1. Environment Variables: Store your secrets as environment variables. These are special variables that live outside your code, making them much harder for attackers to access. Flutter provides ways to read these variables during runtime.
  2. .env Files: Use a .env file to manage your environment variables locally during development. Be sure to add this file to your .gitignore so it's never accidentally committed to your code repository.
  3. Secret Management Solutions: For production environments, consider using secure secret management services. These are like high-security vaults for your secrets. Flutter can integrate with these services to fetch secrets when needed. Popular options include:
  • Firebase: Google’s mobile development platform offers a secure way to store and manage secrets for your Flutter apps.
  • AWS Secrets Manager: Amazon’s cloud-based solution for managing secrets in a scalable and auditable way.
  • HashiCorp Vault: A popular open-source tool for securely storing and controlling access to secrets.

Implementation

Generally, there are two primary strategies for managing sensitive information within your code: encrypting secrets before they’re added to version control (like Git) or using tools that automatically decrypt and add the secrets to your code during the continuous integration/continuous delivery (CI/CD) pipeline.

The best approach depends on the unique requirements and setup of your project. In the context of Flutter development, we’ll focus on how to inject secrets into your Flutter code during the CI/CD process. This can be accomplished using the --dart-define-from-file argument, a practical solution for setting up environment variables in Flutter applications.

Let’s walk through a practical example of how to securely handle secrets in your Flutter app, using a combination of environment variables and a CI/CD pipeline like in GitHub:

Create Your Secrets File:

  • Make a file called env.json to hold your sensitive information (API keys, tokens, etc.).
  • Important: This file should not be added to your version control system (like Git).

Inject Secrets into Your App:

  • Use the --dart-define-from-file flag to read the values from env.json and make them available within your Flutter app.
  • You can then display these secrets in your app’s interface (for testing purposes) or use them securely in your logic.

Prepare for GitHub Action:

  • Create a new env.json file with different values for your GitHub Action build environment.
  • Encode this file to Base64 to ensure safe transmission.
  • Add the Base64-encoded data as an environment variable in your GitHub Action project settings.

Configure YAML File:

  • In your GitHub Action workflow, set up a script to:
  • Read the Base64-encoded environment variable.
  • Decode it back into the env.json format.
  • Inject the decoded values into your Flutter build process.

Build Your APK:

  • Trigger a build on every push in master branch. The secrets will be automatically injected, and your APK will be generated with the correct configuration for the target environment.

Setup env.json Variable

Lets now create a new env.json file

{
"secret1": "Prod One",
"secret2": "Prod Two",
"secret3": "Prod Three"
}

We now need to encode our env.jsonto base64. The specific method to achieve this will vary according to your operating system:

  • Mac -> cat env.json | base64 | pbcopy
  • Linux -> sudo apt-get install xclip; cat env.json | base64 | xclip selection clipboard
  • Windows -> [Convert]::ToBase64String(IO.File]::ReadAllBytes("env.json")) | Set-Clipboard

These commands will encode the file and copy it to your clipboard. Now it’s time to add the variable to GitHub Action secrets:

Additional Tips:

  • Encrypt at Rest: If you must store secrets in a file, encrypt them.
  • Least Privilege: Give your app only the permissions it absolutely needs to access secrets.
  • Regularly Rotate Secrets: Change your secrets periodically to minimize the impact of potential breaches.

By following these practices, you can rest assured that your Flutter secrets are locked up tight, adding an extra layer of protection to your app and ensuring the safety of your users’ data.

Continuous Integration and Continuous Testing

Continuous Integration (CI) and Continuous Testing (CT) practices ensure consistent testing and validation of software throughout the development lifecycle. CI/CT pipelines automate the build, test, and deployment processes, enabling developers to promptly receive feedback on code modifications and detect issues at an early stage. Below is an example yaml script to enable the automatic testing using Github action:

  • staging.yml; functions to check whether the codebase in the staging branch is free from errors when performing flutter analysis. This script is only triggered when there is a commit on the staging branch.
name: Staging

# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the develop branch
push:
branches: [staging]
pull_request:
branches: [staging]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
name: Analyze and Test
# The type of runner that the job will run on
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@v4

- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'

- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.9'
channel: 'stable'

- name: Decode env
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
run: echo $ENV_FILE | base64 --decode > env.json

- name: Validate env.json
run: ls -l env.json

- name: Get packages
run: flutter pub get

- name: Analyze
run: dart analyze

- name: Test
run: flutter test --dart-define-from-file=env.json --coverage
  • pre-release.yml; serves to check whether the application build process can run without errors. If there are no errors, the APK file can be accessed as an artifact. This script is only triggered when there is a pull request from the staging branch to the main branch.
name: Pre-Release

# Controls when the workflow will run
on:
# Triggers the workflow on pull request events but only for the main branch
pull_request:
branches: [master]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "Build and Pre-Release APK"
releases:
name: Build and Pre-Release APK
# The type of runner that the job will run on
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@v4

- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "11"

- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"

- name: Get packages
run: flutter pub get

- name: Decode env
run: echo $ENV_FILE | base64 --decode > env.json

- name: Generate Java keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
run: echo "$KEY_JKS" | base64 --decode > release-keystore.jks

- name: Build APK
env:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: flutter build apk --split-per-abi --dart-define-from-file=env.json
  • release.yml; functions to carry out the application build process and release applications as Releases on the GitHub repository. This script is only triggered when there is a commit on the main branch.
# This is a basic workflow to help you get started with Actions
name: Release

# Controls when the workflow will run
on:
# Triggers the workflow on push events but only for the main branch
push:
branches: [master]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "Build and Release APK"
releases:
name: Build and Release APK
# The type of runner that the job will run on
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@v4

- name: Get version from pubspec.yaml
id: version
run: echo "::set-output name=version::$(grep "version:" pubspec.yaml | cut -c10-)"

- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "11"

- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"

- name: Get packages
run: flutter pub get

- name: Decode env
run: echo $ENV_FILE | base64 --decode > env.json

- name: Generate Java keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
run: echo "$KEY_JKS" | base64 --decode > release-keystore.jks

- name: Build APK
env:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: flutter build apk --split-per-abi --release --dart-define-from-file=env.json

- name: Get current date
id: date
run: echo "::set-output name=date::$(TZ='Asia/Jakarta' date +'%A %d-%m-%Y %T WIB')"

- name: Release APK
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/app/outputs/flutter-apk/*.apk"
body: "Published at ${{ steps.date.outputs.date }}"
name: "v${{ steps.version.outputs.version }}"
token: ${{ secrets.GH_TOKEN }}
tag: ${{ steps.version.outputs.version }}
  • Save the file and push to the repository. Check whether the app was successfully created and released by GitHub Actions automatically.

Conclusion

Testing and quality assurance are not just afterthoughts in Flutter app development; they are integral components that ensure your app is reliable, robust, and user-friendly. Unit testing, integration testing, and end-to-end testing are essential tools in your QA arsenal, helping you verify that your app functions as expected, integrates seamlessly with other components, and delivers a smooth user experience. By embracing these testing methodologies and leveraging Flutter’s built-in testing frameworks, you’re well on your way to creating high-quality Flutter apps.

However, testing is just one piece of the puzzle. Static code analysis, as we’ve explored, plays a pivotal role in upholding code quality. The Dart and Flutter analyzers act as vigilant guardians, proactively identifying potential issues before they escalate into major problems. This early detection not only saves valuable development time but also contributes to a more maintainable and scalable codebase.

But quality assurance doesn’t stop there. A holistic approach to QA in Flutter involves continuous attention to code style, adherence to best practices, and a commitment to accessibility and performance optimization. By integrating these practices into your development workflow, you create a virtuous cycle of improvement, ensuring that your Flutter apps consistently meet and exceed user expectations.

--

--

Vicky Maulana
Vicky Maulana

Written by Vicky Maulana

Computer Science Student at University of Indonesia

No responses yet