MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Flutter网络请求的Mock测试:模拟真实场景的开发技巧

2021-08-307.9k 阅读

一、Flutter 网络请求基础

在 Flutter 开发中,网络请求是非常常见的操作。无论是获取后端数据来展示,还是向服务器发送用户输入的数据,都离不开网络请求。Flutter 提供了多种库来处理网络请求,其中最常用的是 http 库。

首先,在 pubspec.yaml 文件中添加 http 依赖:

dependencies:
  http: ^0.13.4

然后导入该库:

import 'package:http/http.dart' as http;

发起一个简单的 GET 请求示例如下:

Future<String> fetchData() async {
  final response = await http.get(Uri.parse('https://example.com/api/data'));
  if (response.statusCode == 200) {
    return response.body;
  } else {
    throw Exception('Failed to load data');
  }
}

在这个示例中,http.get 方法接收一个 Uri 对象,该对象指向要请求的 API 地址。await 关键字用于等待网络请求完成,获取到 Response 对象。如果响应状态码为 200,表示请求成功,返回响应体;否则,抛出异常。

POST 请求的示例如下:

Future<String> postData(Map<String, dynamic> data) async {
  final response = await http.post(
    Uri.parse('https://example.com/api/submit'),
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
    },
    body: jsonEncode(data),
  );
  if (response.statusCode == 200) {
    return response.body;
  } else {
    throw Exception('Failed to post data');
  }
}

这里 http.post 方法除了 Uri 对象外,还设置了请求头 headers 和请求体 bodyjsonEncode 方法将 Dart 的 Map 对象转换为 JSON 字符串作为请求体发送。

二、为什么需要 Mock 测试

  1. 隔离外部依赖 在实际开发中,网络请求依赖于外部服务器。服务器可能会出现不可用、响应缓慢或者返回数据格式发生变化等情况。通过 Mock 测试,可以将网络请求这一外部依赖隔离出来,使得测试不受服务器状态的影响。例如,在开发一个新闻应用时,新闻数据是从服务器获取的。如果服务器出现故障,正常的单元测试就无法进行。而使用 Mock 测试,可以模拟服务器返回数据,保证测试的正常运行。
  2. 提高测试效率 网络请求通常比较耗时,尤其是在测试环境下,可能需要多次请求不同的数据来验证各种逻辑。Mock 测试可以快速地返回预设的数据,大大提高了测试的执行速度。比如,在一个电商应用中,每次获取商品列表都需要向服务器请求数据,这个过程可能需要几秒甚至更长时间。如果使用 Mock 测试,直接返回模拟的商品列表数据,测试可以在瞬间完成。
  3. 精确控制测试条件 通过 Mock 测试,可以精确控制返回的数据内容、状态码等,从而覆盖各种可能的情况。例如,在测试网络请求失败的逻辑时,可以通过 Mock 测试返回 404、500 等不同的错误状态码,观察应用的处理情况。而在真实网络请求中,要获取特定的错误状态码可能比较困难,或者需要与服务器端配合才能实现。

三、Flutter 中实现 Mock 测试的工具

  1. Mockito Mockito 是一个流行的用于 Dart 语言的 Mock 框架。它可以帮助我们创建 Mock 对象,模拟方法调用并验证这些调用。 首先,在 pubspec.yaml 文件中添加 mockito 依赖:
dev_dependencies:
  mockito: ^5.0.16

然后运行 flutter pub get 来安装依赖。

假设我们有一个 NetworkService 类负责网络请求:

class NetworkService {
  Future<String> fetchData() async {
    final response = await http.get(Uri.parse('https://example.com/api/data'));
    if (response.statusCode == 200) {
      return response.body;
    } else {
      throw Exception('Failed to load data');
    }
  }
}

要使用 Mockito 对其进行测试,可以这样写:

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
import 'package:your_package/network_service.dart';

// 创建一个 Mock 类,继承自 Mock 并实现 NetworkService 接口
class MockNetworkService extends Mock implements NetworkService {}

void main() {
  group('NetworkService tests', () {
    test('fetchData should return data when successful', () async {
      final mockService = MockNetworkService();
      when(mockService.fetchData()).thenAnswer((_) async => 'Mocked data');
      final result = await mockService.fetchData();
      expect(result, 'Mocked data');
    });

    test('fetchData should throw exception when fails', () async {
      final mockService = MockNetworkService();
      when(mockService.fetchData()).thenThrow(Exception('Mocked failure'));
      expect(() => mockService.fetchData(), throwsException);
    });
  });
}

在上述代码中,首先创建了一个 MockNetworkService 类,它继承自 Mock 并实现了 NetworkService 接口。然后在测试用例中,使用 when 方法来定义 fetchData 方法的模拟行为,thenAnswer 用于返回预设的数据,thenThrow 用于抛出预设的异常。

  1. HttpOverrides HttpOverrides 是 Flutter 提供的一种机制,用于全局替换 HttpClient 的实现。通过这种方式,我们可以在测试中拦截网络请求,并返回模拟的响应。 假设我们还是对上述 NetworkService 进行测试:
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:your_package/network_service.dart';

void main() {
  group('NetworkService tests with HttpOverrides', () {
    test('fetchData should return data when successful', () async {
      final mockResponse = http.Response('Mocked data', 200);
      HttpOverrides.runZoned(() async {
        HttpOverrides.global = new MockHttpOverrides(mockResponse);
        final service = NetworkService();
        final result = await service.fetchData();
        expect(result, 'Mocked data');
      }, createNewZone: true);
    });

    test('fetchData should throw exception when fails', () async {
      final mockResponse = http.Response('Error', 404);
      HttpOverrides.runZoned(() async {
        HttpOverrides.global = new MockHttpOverrides(mockResponse);
        final service = NetworkService();
        expect(() => service.fetchData(), throwsException);
      }, createNewZone: true);
    });
  });
}

class MockHttpOverrides extends HttpOverrides {
  final http.Response mockResponse;
  MockHttpOverrides(this.mockResponse);

  @override
  HttpClient createHttpClient(SecurityContext? context) {
    return super.createHttpClient(context)
     ..addListener((request) async {
       request.close().then((_) => request.reply(mockResponse));
     });
  }
}

在这个示例中,定义了 MockHttpOverrides 类继承自 HttpOverrides,并重写了 createHttpClient 方法。在方法中,拦截请求并返回预设的 mockResponseHttpOverrides.runZoned 方法用于在一个新的区域中运行测试代码,确保 HttpOverrides 的设置只在测试范围内生效。

四、模拟真实场景的 Mock 测试技巧

  1. 模拟不同的网络状态 在真实场景中,网络状态可能是不稳定的,例如网络延迟、网络中断等。我们可以通过 Mock 测试来模拟这些情况。 使用 Mockito 模拟网络延迟:
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:your_package/network_service.dart';

class MockNetworkService extends Mock implements NetworkService {}

void main() {
  group('NetworkService tests', () {
    test('fetchData should handle delay', () async {
      final mockService = MockNetworkService();
      when(mockService.fetchData()).thenAnswer((_) async {
        await Future.delayed(Duration(seconds: 3));
        return 'Mocked data after delay';
      });
      final start = DateTime.now();
      final result = await mockService.fetchData();
      final end = DateTime.now();
      final duration = end.difference(start);
      expect(duration.inSeconds >= 3, true);
      expect(result, 'Mocked data after delay');
    });
  });
}

在这个测试用例中,thenAnswer 方法中使用 Future.delayed 模拟了 3 秒的网络延迟。通过记录开始和结束时间,验证实际执行时间是否大于等于 3 秒。

使用 HttpOverrides 模拟网络中断:

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:your_package/network_service.dart';

void main() {
  group('NetworkService tests with HttpOverrides', () {
    test('fetchData should handle network interruption', () async {
      HttpOverrides.runZoned(() async {
        HttpOverrides.global = new MockHttpOverridesForInterruption();
        final service = NetworkService();
        expect(() => service.fetchData(), throwsException);
      }, createNewZone: true);
    });
  });
}

class MockHttpOverridesForInterruption extends HttpOverrides {
  @override
  HttpClient createHttpClient(SecurityContext? context) {
    return super.createHttpClient(context)
     ..addListener((request) {
       request.close().then((_) => request.fail(ConnectionFailure()));
     });
  }
}

class ConnectionFailure implements Exception {}

在这个示例中,MockHttpOverridesForInterruption 类中的 createHttpClient 方法使用 request.fail 模拟网络中断,抛出 ConnectionFailure 异常,从而验证 NetworkService 对网络中断的处理。

  1. 模拟复杂的 API 响应结构 实际的 API 响应可能包含复杂的 JSON 结构,我们需要模拟这样的响应来测试解析逻辑。 假设 API 返回的数据结构如下:
{
  "data": {
    "id": 1,
    "name": "Example Name",
    "details": {
      "description": "This is an example description",
      "price": 10.99
    }
  },
  "status": "success"
}

定义 Dart 数据模型类:

class ApiResponse {
  final Data data;
  final String status;

  ApiResponse({required this.data, required this.status});

  factory ApiResponse.fromJson(Map<String, dynamic> json) {
    return ApiResponse(
      data: Data.fromJson(json['data']),
      status: json['status'],
    );
  }
}

class Data {
  final int id;
  final String name;
  final Details details;

  Data({required this.id, required this.name, required this.details});

  factory Data.fromJson(Map<String, dynamic> json) {
    return Data(
      id: json['id'],
      name: json['name'],
      details: Details.fromJson(json['details']),
    );
  }
}

class Details {
  final String description;
  final double price;

  Details({required this.description, required this.price});

  factory Details.fromJson(Map<String, dynamic> json) {
    return Details(
      description: json['description'],
      price: json['price'].toDouble(),
    );
  }
}

使用 Mockito 模拟这样的响应并测试解析逻辑:

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:your_package/network_service.dart';
import 'package:your_package/api_response.dart';

class MockNetworkService extends Mock implements NetworkService {}

void main() {
  group('NetworkService tests', () {
    test('fetchData should parse complex response', () async {
      final mockService = MockNetworkService();
      final mockJson = {
        "data": {
          "id": 1,
          "name": "Example Name",
          "details": {
            "description": "This is an example description",
            "price": 10.99
          }
        },
        "status": "success"
      };
      when(mockService.fetchData()).thenAnswer((_) async => jsonEncode(mockJson));
      final result = await mockService.fetchData();
      final apiResponse = ApiResponse.fromJson(jsonDecode(result));
      expect(apiResponse.status, 'success');
      expect(apiResponse.data.id, 1);
      expect(apiResponse.data.name, 'Example Name');
      expect(apiResponse.data.details.description, 'This is an example description');
      expect(apiResponse.data.details.price, 10.99);
    });
  });
}

在这个测试用例中,首先定义了 mockJson 模拟 API 响应的 JSON 数据。然后使用 jsonEncode 将其转换为字符串,作为 fetchData 方法的模拟返回值。最后通过 jsonDecode 解析返回的字符串,并验证解析后的 ApiResponse 对象的各个属性是否正确。

  1. 模拟 API 版本变化 随着项目的发展,API 可能会进行版本更新,接口地址或者返回数据格式可能会发生变化。我们可以通过 Mock 测试来模拟 API 版本变化的情况。 假设旧版本 API 的地址是 https://example.com/v1/api/data,新版本是 https://example.com/v2/api/data,并且返回数据格式也有所不同。 旧版本返回数据格式:
{
  "oldData": [
    {
      "name": "Old Name 1",
      "value": 10
    },
    {
      "name": "Old Name 2",
      "value": 20
    }
  ]
}

新版本返回数据格式:

{
  "newData": [
    {
      "title": "New Title 1",
      "amount": 100
    },
    {
      "title": "New Title 2",
      "amount": 200
    }
  ]
}

定义旧版本和新版本的数据模型类:

class OldApiResponse {
  final List<OldData> oldData;

  OldApiResponse({required this.oldData});

  factory OldApiResponse.fromJson(Map<String, dynamic> json) {
    return OldApiResponse(
      oldData: (json['oldData'] as List)
         .map((data) => OldData.fromJson(data))
         .toList(),
    );
  }
}

class OldData {
  final String name;
  final int value;

  OldData({required this.name, required this.value});

  factory OldData.fromJson(Map<String, dynamic> json) {
    return OldData(
      name: json['name'],
      value: json['value'],
    );
  }
}

class NewApiResponse {
  final List<NewData> newData;

  NewApiResponse({required this.newData});

  factory NewApiResponse.fromJson(Map<String, dynamic> json) {
    return NewApiResponse(
      newData: (json['newData'] as List)
         .map((data) => NewData.fromJson(data))
         .toList(),
    );
  }
}

class NewData {
  final String title;
  final int amount;

  NewData({required this.title, required this.amount});

  factory NewData.fromJson(Map<String, dynamic> json) {
    return NewData(
      title: json['title'],
      amount: json['amount'],
    );
  }
}

使用 Mockito 模拟不同版本的 API 响应并测试:

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:your_package/network_service.dart';
import 'package:your_package/old_api_response.dart';
import 'package:your_package/new_api_response.dart';

class MockNetworkService extends Mock implements NetworkService {}

void main() {
  group('NetworkService tests for API version change', () {
    test('fetchData should handle old API version', () async {
      final mockService = MockNetworkService();
      final mockJson = {
        "oldData": [
          {
            "name": "Old Name 1",
            "value": 10
          },
          {
            "name": "Old Name 2",
            "value": 20
          }
        ]
      };
      when(mockService.fetchData(version: 1)).thenAnswer((_) async => jsonEncode(mockJson));
      final result = await mockService.fetchData(version: 1);
      final oldApiResponse = OldApiResponse.fromJson(jsonDecode(result));
      expect(oldApiResponse.oldData.length, 2);
      expect(oldApiResponse.oldData[0].name, 'Old Name 1');
      expect(oldApiResponse.oldData[0].value, 10);
    });

    test('fetchData should handle new API version', () async {
      final mockService = MockNetworkService();
      final mockJson = {
        "newData": [
          {
            "title": "New Title 1",
            "amount": 100
          },
          {
            "title": "New Title 2",
            "amount": 200
          }
        ]
      };
      when(mockService.fetchData(version: 2)).thenAnswer((_) async => jsonEncode(mockJson));
      final result = await mockService.fetchData(version: 2);
      final newApiResponse = NewApiResponse.fromJson(jsonDecode(result));
      expect(newApiResponse.newData.length, 2);
      expect(newApiResponse.newData[0].title, 'New Title 1');
      expect(newApiResponse.newData[0].amount, 100);
    });
  });
}

在这个示例中,假设 NetworkServicefetchData 方法增加了一个 version 参数来区分不同的 API 版本。通过 when 方法分别模拟了旧版本和新版本的 API 响应,并验证相应的数据解析是否正确。

五、Mock 测试与持续集成

  1. 在持续集成环境中运行 Mock 测试 持续集成(CI)是现代软件开发流程中的重要环节,它可以自动运行测试,确保代码质量。在 CI 环境中运行 Mock 测试可以避免因网络问题导致的测试失败,保证测试的稳定性。 常见的 CI 平台如 GitHub Actions、GitLab CI/CD 等,都可以很方便地集成 Flutter 项目的测试。 以 GitHub Actions 为例,在项目根目录创建 .github/workflows 目录,并在其中创建一个 YAML 文件,例如 flutter_test.yml
name: Flutter Test
on:
  push:
    branches:
      - main
  pull_request:
jobs:
  flutter_test:
    runs-on: ubuntu - latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Set up Flutter
        uses: subosito/flutter - action@v1
        with:
          flutter_version: 'stable'
      - name: Install dependencies
        run: flutter pub get
      - name: Run tests
        run: flutter test

这个配置文件定义了在 main 分支推送代码或者有拉取请求时,在 Ubuntu 最新版本环境中安装 Flutter,获取项目依赖,并运行 flutter test 命令。由于 Mock 测试不依赖于真实网络,所以在这个 CI 环境中可以稳定运行。

  1. 确保 Mock 测试覆盖率 为了保证代码的质量,需要确保 Mock 测试具有足够的覆盖率。可以使用 flutter test --coverage 命令来生成测试覆盖率报告。 运行该命令后,会在项目根目录生成 coverage 目录,其中包含 HTML 格式的覆盖率报告。打开 coverage/index.html 文件,可以查看详细的覆盖率信息,了解哪些代码被测试覆盖,哪些没有。 为了提高覆盖率,需要编写更多的测试用例,尤其是针对复杂逻辑和边界情况的测试。例如,在处理网络请求的重试逻辑时,需要编写测试用例来验证不同重试次数、不同重试间隔下的行为。通过不断完善 Mock 测试,提高代码的可靠性和稳定性。

六、Mock 测试的局限性

  1. 无法完全模拟真实场景 尽管 Mock 测试可以模拟很多真实场景,但仍然无法完全复制真实网络环境的复杂性。例如,真实网络中的带宽限制、网络拓扑结构等因素很难在 Mock 测试中精确模拟。这可能导致在 Mock 测试中通过的代码,在实际网络环境中出现问题。
  2. Mock 配置可能过时 随着项目的发展,API 可能会发生变化。如果 Mock 测试的配置没有及时更新,可能会导致测试结果不准确。例如,API 返回的数据结构发生了变化,但 Mock 测试中仍然使用旧的数据结构进行模拟,这样可能会掩盖实际代码中的解析错误。
  3. 过度依赖 Mock 可能导致代码耦合 如果在代码中过度依赖 Mock 对象,可能会导致代码与测试之间的耦合度增加。例如,为了方便 Mock 某个方法,可能会将该方法设计得过于简单或者暴露过多的内部细节,从而影响代码的整体设计和可维护性。

在进行 Mock 测试时,需要清楚地认识到这些局限性,并采取相应的措施来尽量减少其影响。例如,定期进行集成测试,结合真实的网络环境进行测试;及时更新 Mock 测试的配置,保持与 API 的一致性;在设计代码时,遵循良好的设计原则,避免过度依赖 Mock 对象。

通过合理使用 Mock 测试,并结合其他测试手段,可以提高 Flutter 应用网络请求部分的代码质量和稳定性,为用户提供更好的使用体验。在实际开发中,要根据项目的具体情况,灵活运用各种 Mock 测试技巧,确保测试的全面性和有效性。