使用 HttpClient 进行 Flutter Widget 测试

Flutter Widget testing with HttpClient

我正在尝试为屏幕而不是主应用程序编写小部件测试。这是我第一次编写小部件测试,我找不到合适的解决方案。 我不知道如何为此编写适当的测试。我尝试编写一个简单的小部件测试,但最终出现如下错误 “警告:此套件中至少有一个测试会创建一个 HttpClient。当 运行一个使用TestWidgetsFlutterBinding的测试套件,全部HTTP 请求将 return 状态码 400,没有网络请求将 实际制作。任何需要真实网络连接的测试和 状态代码将失败。 要测试需要 HttpClient 的代码,请提供您自己的 HttpClient 对被测代码的实现,以便您的测试可以 始终如一地为被测代码提供可测试的响应。” 我刚刚开始学习它,请帮助我。 注意:我的测试只是编写一个用于查找文本小部件的基本测试。

class BookingDetails extends StatefulWidget {
final booking;
BookingDetails(this.booking);
@override
_BookingDetailsState createState() => _BookingDetailsState();
}

class _BookingDetailsState extends State<BookingDetails>
with AutomaticKeepAliveClientMixin {

Row _buildTeacherInfo(Map<String, dynamic> teacherData) {
return teacherData != null
    ? Row(
        children: <Widget>[
          CircleAvatar(
            radius: 53,
            backgroundColor: MyColors.primary,
            child: CircleAvatar(
              radius: 50.0,
              backgroundImage: teacherData['user']['img_url'] == null ||
                      teacherData['user']['img_url'] == ''
                  ? AssetImage('assets/images/placeholder_avatar.png')
                  : NetworkImage(teacherData['user']['img_url']),
              backgroundColor: Colors.transparent,
            ),
          ),
          SizedBox(width: 20.0),
          Column(
            children: <Widget>[
              Container(
                child: Column(
                  children: <Widget>[
                    Text(
                      '${teacherData['user']['first_name']} ',
                      style: AppStyles.textHeader1Style,
                    ),
                    Text(
                      '${teacherData['user']['last_name']}',
                      style: AppStyles.textHeader1Style,
                    ),
                  ],
                ),
              ),
              ElevatedButton(
                onPressed: () {
                  //View Profile method
                },
                style: ElevatedButton.styleFrom(
                  primary: MyColors.primary,
                  shape: const RoundedRectangleBorder(
                      borderRadius: BorderRadius.all(Radius.circular(25))),
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    Icon(Icons.next_plan_outlined),
                    SizedBox(width: 10.0),
                    Text('VIEW PROFILE'),
                  ],
                ),
              ),
            ],
          ),
        ],
      )
    : Row(
        children: <Widget>[
          CircleAvatar(
            radius: 48,
            backgroundColor: MyColors.primary,
            child: CircleAvatar(
              radius: 45.0,
              backgroundImage:
                  AssetImage('assets/images/placeholder_avatar.png'),
              backgroundColor: Colors.transparent,
            ),
          ),
          SizedBox(width: 20.0),
          Expanded(
            child: Text(
              'Teacher allocation in progress',
              style: AppStyles.textHeader1Style,
            ),
          )
        ],
      );
  }

Widget _buildBookingDetails(
Map<String, dynamic> booking,
List<dynamic> campusData, // one campus' data is an array for some reason.
Map<String, dynamic> instData,
) {
return Expanded(
  child: Scrollbar(
    child: ListView(
      children: [
        ListTile(
          leading: Icon(Icons.location_on),
          title: Text(
            '${campusData[0]['address_line1']},'
            ' ${campusData[0]['suburb']}, '
            '${campusData[0]['state']} ${campusData[0]['postcode']} ',
            style: AppStyles.textHeader3Style,
          ),
        ),
}

@override
Widget build(BuildContext context) {
super.build(context);
return FutureBuilder(
  future: Future.wait([_teacherData, _campusData, _classData, _instData]),
  builder: (context, snapshot) => snapshot.connectionState ==
          ConnectionState.waiting
      ? MyLoadingScreen(message: 'Loading booking data, please wait...')
      : snapshot.hasData
          ? SafeArea(
              child: Container(
                margin: const EdgeInsets.only(top: 30.0),
                child: Padding(
                  padding: const EdgeInsets.all(30),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      _buildTeacherInfo(snapshot.data[0]),
                      Divider(color: MyColors.dividerColor),
                      SizedBox(height: 10),

                      const SizedBox(height: 10),
                      Divider(
                        color: MyColors.primary,
                        thickness: 1,
                      ),
                      const SizedBox(height: 10),
                      _buildBookingDetails(
                        widget.booking,
                        snapshot.data[1],
                        snapshot.data[3],
                      ),
                      SizedBox(height: 10),
                      Divider(
                        color: MyColors.primary,
                        thickness: 1,
                      ),
                      SizedBox(height: 10),
                      Center(
                        child: widget.booking['cancelled_by_inst'] == true
                            ? Text(
                                'Canceled',
                                style: AppStyles.textHeader3StyleBold,
                              )
                            : widget.booking['teacher_id'] == null
                                ? Center(
                                    child: Text(
                                      'Teacher Allocation in Progress',
                                      style: AppStyles.textHeader3StyleBold,
                                    ),
                                  )
                                : null,
                      ),
                     }

我已将您的代码缩减为以下最小版本,以便能够执行它:

snippet.dart:

import 'package:flutter/material.dart';
import 'dart:convert';
import 'api.dart';

class BookingDetails extends StatefulWidget {
  final Map<String, String> booking;
  BookingDetails(this.booking);
  @override
  _BookingDetailsState createState() => _BookingDetailsState();
}

class _BookingDetailsState extends State<BookingDetails> {
  late Future _campusData;

  Future<dynamic> _fetchCampusData() async {
    var campusID = widget.booking['campus_id'];
    if (campusID != null) {
      var response = await api.getCampusByID(campusID);
      return json.decode(response.body);
    }
  }

  @override
  void initState() {
    _campusData = _fetchCampusData();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: _campusData,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return const Text('Displaying data');
          } else if (snapshot.hasError) {
            return const Text('An error occurred.');
          } else {
            return const Text('Loading...');
          }
        }

    );
  }
}

api.dart:

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

final _ApiClient api = _ApiClient();

class _ApiClient {
  Future<http.Response> getCampusByID(String id) async {
    var url = Uri.parse('https://run.mocky.io/v3/49c23ebc-c107-4dae-b1c6-5d325b8f8b58');
    var response = await http.get(url);
    if (response.statusCode >= 400) {
      throw "An error occurred";
    }
    return response;
  }
}

这是一个重现您描述的错误的小部件测试:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:widget_test/snippet.dart';

void main() {

  testWidgets('Should test widget with http call', (WidgetTester tester) async {
    var booking = <String, String>{
      'campus_id': '2f4fccd2-e199-4989-bad3-d8c48e66a15e'
    };

    await tester.pumpWidget(TestApp(BookingDetails(booking)));
    expect(find.text('Loading...'), findsOneWidget);

    await tester.pump();
    expect(find.text('Displaying data'), findsOneWidget);
  });
}

class TestApp extends StatelessWidget {
  final Widget child;

  TestApp(this.child);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: child,
    );
  }
}

这里是报错信息,供参考:

Test failed. See exception logs above.
The test description was: Should test widget with http call

Warning: At least one test in this suite creates an HttpClient. When
running a test suite that uses TestWidgetsFlutterBinding, all HTTP
requests will return status code 400, and no network request will
actually be made. Any test expecting a real network connection and
status code will fail.
To test code that needs an HttpClient, provide your own HttpClient
implementation to the code under test, so that your test can
consistently provide a testable response to the code under test.

解决方案

该错误告诉您问题所在:您不能在小部件测试中执行 HTTP 调用。因此,您需要模拟该 HTTP 调用,以便调用模拟而不是真正的 HTTP 调用。您可以使用许多选项来做到这一点,例如使用 mockito 包。

这里是使用 nock 包的可能解决方案,它在 HTTP 级别模拟 HTTP 响应。

pubspec.yaml:

dev_dependencies:
  nock: ^1.1.2

小部件测试:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nock/nock.dart';
import 'package:widget_test/snippet.dart';

void main() {
  setUpAll(nock.init);

  setUp(() {
    nock.cleanAll();
  });

  testWidgets('Should test widget with http call', (WidgetTester tester) async {
    nock('https://run.mocky.io')
        .get('/v3/49c23ebc-c107-4dae-b1c6-5d325b8f8b58')
      .reply(200, json.encode('{"id": "49c23ebc-c107-4dae-b1c6-5d325b8f8b58", "name": "Example campus" }'));

    var booking = <String, String>{
      'campus_id': '2f4fccd2-e199-4989-bad3-d8c48e66a15e'
    };

    await tester.pumpWidget(TestApp(BookingDetails(booking)));
    expect(find.text('Loading...'), findsOneWidget);

    await tester.pump();
    expect(find.text('Displaying data'), findsOneWidget);
  });
}

class TestApp extends StatelessWidget {
  final Widget child;

  TestApp(this.child);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: child,
    );
  }
}