使用HttpClient进行Flutter小部件测试

8

我正在尝试为屏幕编写小部件测试,而不是主应用程序。这是我第一次编写小部件测试,我找不到一个合适的解决方案来解决这个问题。 我不知道如何为这个问题编写适当的测试。我尝试编写一个简单的小部件测试,结果出现了以下错误: “警告:此套件中至少有一个测试创建了一个HttpClient。当运行使用TestWidgetsFlutterBinding的测试套件时,所有HTTP请求都将返回状态码400,并且实际上不会进行任何网络请求。任何需要真实网络连接和状态码的测试都将失败。 要测试需要HttpClient的代码,请向被测试的代码提供自己的HttpClient实现,以便您的测试可以始终向被测试的代码提供可测试的响应。” 我刚开始学习,请帮忙。 注意:我的测试只是编写了一个查找Text小部件的基本测试。

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,
                      ),
                     }

当您发布代码片段时,如果它是问题的最小表示形式,即省略与问题无关的所有内容,那将非常有帮助。 - Janux
尽力让屏幕内部有所启示。 - Gurjit Singh
2个回答

10
我已将您的代码简化为以下最小版本,以便执行:
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.

解决方案

错误告诉你问题所在:你不能在widget测试中执行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,
    );
  }
}

谢谢,Janux,它对我的学习有很大帮助,但是我有一个问题,为什么有时候当我想测试特定的小部件时会出现关于小部件树的错误? - Gurjit Singh
1
没有看到那个错误的详细信息,我无法判断。不过,我猜想这个问题与如何使用HTTP调用为小部件创建测试的问题无关。因此,我建议您进行进一步的调查,如果必要的话,可以创建一个新的问题。 - Janux
1
我解决了这个问题,感谢Janux。 - Gurjit Singh

1
写集成测试时,我也遇到了同样的问题。 当我登录我的应用程序时,API返回400错误,并且抛出了与下面提到的相同的错误。
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.

我已经按照下面的方式修复了这个问题:
我在集成测试的main()函数中添加了IntegrationTestWidgetsFlutterBinding.ensureInitialized();
例如:在实施解决方案之前,代码如下:
void main() {
    testWidgets('Sign In Test', (WidgetTester tester) async {
    WidgetsFlutterBinding.ensureInitialized();
    // MY TEST HERE
  });
}

在我实施解决方案之后,代码如下:

void main() {
    IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    testWidgets('Sign In Test', (WidgetTester tester) async {
    WidgetsFlutterBinding.ensureInitialized();
    // MY TEST HERE
  });
}

实施后,当我点击登录按钮时,应用程序实际上是针对该按钮调用API,并成功接收到响应。
不要忘记导入import 'package:integration_test/integration_test.dart'; 确保调用await tester.pumpAndSettle();以完成按钮(Widget)或任何已计划完成的微任务所需的所有帧!
希望能有所帮助...

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接