sandwich_shop

Worksheet 8 — Integration Testing, Firebase, and Deployment

What you need to know beforehand

Ensure that you have already completed the following:

Getting help

To get support with this worksheet, join the Discord channel and ask your questions there. Otherwise, attend your timetabled session and ask a member of staff for help.

Getting started

For this worksheet, you need to start with the code from branch 8 of our GitHub repository. You can either clone the repository and checkout branch 8:

git clone https://github.com/manighahrmani/sandwich_shop.git
cd sandwich_shop
git checkout 8

Or manually ensure your code matches the repository. Run the app to make sure everything works as expected before proceeding.

Integration Testing

So far, we have written unit tests to check individual functions or classes and widget tests to verify that our widgets look and behave as expected. However, these tests don’t tell us if the different parts of our app work together correctly. Integration tests verify that different parts of your app work together properly.

Integration tests verify the behaviour of the complete app. They simulate an end-to-end user flow from launching the app to navigating between screens and performing various actions.

For a more detailed introduction to integration testing in Flutter, you can read the official documentation on the topic: Introduction to integration testing. In this worksheet, we’ll provide a brief example of integration tests to verify the functionality of our app.

Writing Integration Tests

Flutter provides the integration_test package for writing integration tests. Let’s add it to your project:

flutter pub add 'dev:integration_test:{"sdk":"flutter"}'

Create a new directory called integration_test in your project root (at the same level as lib and test):

sandwich_shop/
├── lib/
├── test/
├── integration_test/
└── ...

Inside the integration_test directory, create a new file called app_test.dart. Below is an example of the integration test code that you can write in such file:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:sandwich_shop/main.dart' as app;
import 'package:sandwich_shop/models/sandwich.dart';
import 'package:sandwich_shop/widgets/common_widgets.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('end-to-end test', () {
    testWidgets('add a sandwich to the cart and verify it is in the cart',
        (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      // Test the initial state of the app (on the order screen)
      expect(find.text('Sandwich Counter'), findsOneWidget);
      expect(find.text('Cart: 0 items - £0.00'), findsOneWidget);
      expect(find.text('Veggie Delight'), findsWidgets);

      final addToCartButton = find.widgetWithText(StyledButton, 'Add to Cart');
      await tester.ensureVisible(addToCartButton); // Scroll if needed
      await tester.pumpAndSettle();

      // Add a sandwich to the cart
      await tester.tap(addToCartButton);
      await tester.pumpAndSettle();

      // Verify cart summary updated
      expect(find.text('Cart: 1 items - £11.00'), findsOneWidget);

      // Find the View Cart button to navigate to the cart
      final viewCartButton = find.widgetWithText(StyledButton, 'View Cart');
      await tester.ensureVisible(viewCartButton);
      await tester.pumpAndSettle();
      await tester.tap(viewCartButton);
      await tester.pumpAndSettle();

      // Verify that we're on the cart screen and the sandwich is there
      expect(find.text('Cart'), findsOneWidget);
      expect(find.text('Veggie Delight'), findsOneWidget);
      expect(find.text('Total: £11.00'), findsOneWidget);
    });

    testWidgets('change sandwich type and add to cart',
        (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      final sandwichDropdown = find.byType(DropdownMenu<SandwichType>);
      await tester.tap(sandwichDropdown);
      await tester.pumpAndSettle();

      await tester.tap(find.text('Chicken Teriyaki').last);
      await tester.pumpAndSettle();

      final addToCartButton = find.widgetWithText(StyledButton, 'Add to Cart');
      await tester.ensureVisible(addToCartButton);
      await tester.pumpAndSettle();

      await tester.tap(addToCartButton);
      await tester.pumpAndSettle();

      final viewCartButton = find.widgetWithText(StyledButton, 'View Cart');
      await tester.ensureVisible(viewCartButton);
      await tester.pumpAndSettle();

      await tester.tap(viewCartButton);
      await tester.pumpAndSettle();

      expect(find.text('Cart'), findsOneWidget);
      expect(find.text('Chicken Teriyaki'), findsOneWidget);
    });

    testWidgets('modify quantity and add to cart', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      final quantitySection = find.text('Quantity: ');
      expect(quantitySection, findsOneWidget);

      // Find the + button that's near the quantity text
      final addButtons = find.byIcon(Icons.add);
      // The + button should be the first one (before the cart + button)
      final quantityAddButton = addButtons.first;

      await tester.tap(quantityAddButton);
      await tester.pumpAndSettle();
      await tester.tap(quantityAddButton);
      await tester.pumpAndSettle();

      expect(find.text('3'), findsOneWidget);

      final addToCartButton = find.widgetWithText(StyledButton, 'Add to Cart');
      await tester.ensureVisible(addToCartButton);
      await tester.pumpAndSettle();

      await tester.tap(addToCartButton);
      await tester.pumpAndSettle();

      expect(find.text('Cart: 3 items - £33.00'), findsOneWidget);
    });

    testWidgets('complete checkout flow', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      final addToCartButton = find.widgetWithText(StyledButton, 'Add to Cart');
      await tester.ensureVisible(addToCartButton);
      await tester.tap(addToCartButton);
      await tester.pumpAndSettle();

      final viewCartButton = find.widgetWithText(StyledButton, 'View Cart');
      await tester.ensureVisible(viewCartButton);
      await tester.tap(viewCartButton);
      await tester.pumpAndSettle();

      final checkoutButton = find.widgetWithText(StyledButton, 'Checkout');
      await tester.tap(checkoutButton);
      await tester.pumpAndSettle();

      expect(find.text('Checkout'), findsOneWidget);
      expect(find.text('Order Summary'), findsOneWidget);

      final confirmPaymentButton = find.text('Confirm Payment');
      await tester.tap(confirmPaymentButton);
      await tester.pumpAndSettle();

      // Wait for payment processing (2 seconds + buffer)
      await tester.pump(const Duration(seconds: 3));

      // Should be back on order screen with empty cart
      expect(find.text('Sandwich Counter'), findsOneWidget);
      expect(find.text('Cart: 0 items - £0.00'), findsOneWidget);
    });

    // Feel free to add more tests (e.g., to check saved orders, etc.)
  });
}

Run the integration tests, use the following command or using the Run button on top of the main function in VS Code:

flutter test integration_test/

Note that the integration tests must be run on a real device or an emulator/simulator.

Firebase

So far, our app has been completely self-contained. All the data is stored on the device, and there is no communication with a server. But that is not how most real-world apps work.

Cloud providers like Amazon Web Services (AWS), Google Cloud Platform (GCP), and Microsoft Azure offer a wide range of services that you can use to build and deploy your app. These services include things like databases, authentication, and hosting.

Firebase is a platform developed by Google for creating mobile and web applications. It provides a suite of tools and services and it is particularly well-suited for Flutter apps.

Here are some features of Firebase you might find useful, particularly for your coursework:

Using Firebase with Flutter

To use Firebase with Flutter, you will need a Google account and you also need to add the Firebase SDK to your app. You can find more information on how to do this in the Firebase documentation page.

We also recommend you checking out this interactive codelab to learn more about using Firebase with Flutter: Get to know Firebase for Flutter.

JSON Serialization

When your app communicates with a backend service like Firebase, it needs a common language to exchange data. That language is almost always JSON (JavaScript Object Notation). It’s a lightweight, text-based format that’s easy for both humans and machines to understand.

For your app to use data from Firebase, you need to convert JSON data into Dart objects (a process called deserialization) and convert Dart objects back into JSON to send them to Firebase (a process called serialization).

Flutter offers a manual serialization but you will most likely be using the automated serialization with code generation.

Let’s say Firebase gives you this JSON for a user:

{
  "name": "John Smith",
  "email": "john@example.com"
}

You could use Dart’s built-in dart:convert library to manually convert this JSON into a Dart map:

import 'dart:convert';

void main() {
  const jsonString = '{"name": "John Smith", "email": "john@example.com"}';

  final Map<String, dynamic> userMap = jsonDecode(jsonString);
  print('Hello, ${userMap['name']}! Your email is ${userMap['
email']}.');
}

A safer way to manually deserialize this JSON is to create a Dart class (a data model) that is capable of converting itself to and from JSON:

class User {
  final String name;
  final String email;

  User(this.name, this.email);

  // A factory constructor for creating User instances from a map.
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      json['name'] as String,
      json['email'] as String,
    );
  }

  // Method for converting User to JSON
  Map<String, dynamic> toJson() => {
    'name': name,
    'email': email,
  };
}

Think of a factory as a special type of constructor that gives you more control. It doesn’t have to create a new instance every time.

Writing fromJson and toJson methods for every model is tedious and error-prone, especially for complex, nested objects. A better approach for larger apps is to let a tool generate this “boilerplate” code for you. We’ll use the popular json_serializable package.

First, add the necessary packages to your pubspec.yaml:

flutter pub add json_annotation
flutter pub add --dev build_runner
flutter pub add --dev json_serializable

Now, you can annotate your model class. The setup looks a bit different, but it saves you a lot of work.

import 'package:json_annotation/json_annotation.dart';

// This file will be generated by the build_runner.
// It connects this file to the generated code.
part 'user.g.dart';

// This annotation tells the generator to create serialization logic for this class.
@JsonSerializable()
class User {
  final String name;
  final String email;

  User(this.name, this.email);

  // Connects to the generated code for deserialization.
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  // Connects to the generated code for serialization.
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

The user.g.dart file is generated by the build runner once you run the following in the terminal. The --delete-conflicting-outputs flag is useful if you change your model and want to regenerate the serialization code without any conflicts from previous versions.

dart run build_runner watch --delete-conflicting-outputs

This command will generate a file named user.g.dart containing all the necessary logic.

As a small note, the _$ prefix in _$UserFromJson is a convention used by the code generator to indicate that these are private, generated functions. You don’t write them or modify them; the build_runner tool creates and updates them for you based on your model class.

Firebase Realtime Database

For your coursework, you might want to use the Firebase Realtime Database to store and sync data for your app. The Realtime Database is a cloud-hosted database. Data is stored as JSON and synchronised in realtime to every connected client.

Here’s a simple example of how you might store order data:

import 'package:firebase_database/firebase_database.dart';

class FirebaseOrderService {
  final DatabaseReference _database = FirebaseDatabase.instance.ref();

  Future<void> saveOrder(Map<String, dynamic> orderData) async {
    try {
      await _database.child('orders').push().set(orderData);
    } catch (e) {
      print('Error saving order: $e');
    }
  }

  Stream<DatabaseEvent> getOrders() {
    return _database.child('orders').onValue;
  }
}

You can learn more about using the Firebase Realtime Database with Flutter in the official documentation: Get Started with Firebase Realtime Database for Flutter.

Deployment

Once you have finished developing and testing your app, you may want to deploy it to your users. The deployment process involves creating a release build of your app, which is an optimised version of your app that is ready for production.

You can find more information about deploying Flutter apps in the official documentation: Deployment. This section will only provide a brief overview of the deployment process.

Flutter has three build modes:

When submitting your coursework, you should ideally create a separate release build of your app (in addition to the debug built you would be using during the demo). You can do this by running the following commands:

flutter clean
flutter pub get
flutter build <target> --release

Replace <target> with the platform you want to build for (e.g., apk for Android, ipa for iOS).

Code Obfuscation

Code obfuscation makes your compiled code harder for others to reverse engineer by replacing class and function names with meaningless symbols. While it’s not foolproof, it adds a layer of protection for your app.

To build with obfuscation:

flutter build apk --obfuscate --split-debug-info=build/app/outputs/symbols

The --split-debug-info flag creates symbol files that you’ll need if you want to debug crash reports from obfuscated builds. Keep these files safe (do not commit them to GitHub).

You can learn more about code obfuscation in the official Flutter documentation: Obfuscate your app.

Preparing for Submission

Before you submit your coursework, there are a few things you should do to make sure your app is ready:

For more information on building and releasing apps for different platforms, you can check out the documentation page on deployment.

Exercises

Complete the exercises below to practice integration testing and explore deployment options.

  1. There are several features of the app that are not covered by unit or widget tests. Think about all the different user journeys in your app and write integration tests to cover them.

    Use your AI assistant to help you identify edge cases and write thorough tests. Remember to test both happy paths (everything works correctly) and error scenarios.

    ⚠️ Show your updated integration tests to a member of staff and describe the changes made for a sign-off.

  2. Create a release build of the sandwich app after you have tested it thoroughly.

    Compare the size and performance of your debug vs release builds. What differences do you notice?

    ⚠️ Show your release build running on a device and your updated README to a member of staff for a sign-off.

  3. This exercise is optional but will prepare you for your coursework if you aim to use Firebase. Choose one or more of the following Firebase features and integrate it into the sandwich app:

    • Authentication: Add user sign-in/sign-up functionality with email/password or Google sign-in
    • Firestore Database: Store saved orders in the cloud instead of locally on the SQLite database
    • Storage: Allow users to upload profile pictures
    • Hosting: Deploy your web version to Firebase Hosting, this way you should be able to access your app from any browser

    This task is optional and there’s no need to show it to a member of staff for a sign-off, but it will demonstrate advanced skills in your coursework.