3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Flutter のテスト(ユニットテスト、ウィジェットテスト、統合テスト)について、hukusuke1007 / flutter_app_template のコードリーディング、テストコマンド実行を通して確認します。

hukusuke1007 / flutter_app_template について

flutter_app_template は初めて使ってみているのですが、Flutter + Firebase アプリのスターターキットになっており、サンプル機能がとても充実しています。

個人開発時に利用しようと思っています。

前提

  • hukusuke1007 / flutter_app_template の導入(git clone から flutter run まで)が完了していること

1. ユニットテスト Unit tests

ユニットテスト は、単一の関数やクラスの動作を確認するためのテストです。

ビジネスロジックが正しく動作するかを検証するために使われます。

実行方法

すべてのユニットテストを実行するには、以下のコマンドを使用します。

flutter test

特定のテストファイルを実行するには、ファイルのパスを指定します。

flutter test test/features/github_users/repositories/github_api_repository_test.dart

実行結果

特定のテストファイルを試しに実行してみます。

実行結果
# r_yamate @ mbp in ~/development/picture_book_log on git:feature/search x [6:54:22] 
$ flutter test test/features/github_users/repositories/github_api_repository_test.dart
00:13 +0: [正常系] GithubApiRepository オフラインテスト (setUpAll)
00:13 +0: [正常系] GithubApiRepository オフラインテスト ユーザーリスト取得APIのレスポンス結果が正しいこと
00:13 +1: [正常系] GithubApiRepository オフラインテスト ユーザーリスト取得APIのレスポンス結果が正しいこと
00:13 +1: [正常系] GithubApiRepository オフラインテスト (tearDownAll)
00:13 +1: [異常系] GithubApiRepository オフラインテスト (setUpAll)
00:13 +1: [異常系] GithubApiRepository オフラインテスト ユーザーリスト取得APIでエラーが発生した場合、AppExceptionが発生すること
00:13 +1: [異常系] GithubApiRepository オフラインテスト ユーザーリスト取得APIでエラーが発生した場合、AppExceptionが発生すること

😡 SHOUT 2024-07-14 07:27:11.167821 [package:yomikey/features/github_users/repositories/github_api_repository.dart 36:14 in GithubApiRepository.fetchUsers] statusCode: 400, message: null
00:13 +2: [異常系] GithubApiRepository オフラインテスト ユーザーリスト取得APIでエラーが発生した場合、AppExceptionが発生すること
00:13 +2: [異常系] GithubApiRepository オフラインテスト (tearDownAll)
00:13 +2: All tests passed! 

実行されたテストコードの確認

該当の実行されたユニットテストのコードを確認します。

確認:test/features/github_users/repositories/github_api_repository_test.dart
import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:yomikey/core/exceptions/app_exception.dart';
import 'package:yomikey/core/repositories/dio/constants.dart';
import 'package:yomikey/core/utils/logger.dart';
import 'package:yomikey/features/github_users/repositories/github_api_client.dart';
import 'package:yomikey/features/github_users/repositories/github_api_repository.dart';

import '../../../utils.dart';
import 'github_api_repository_test.mocks.dart';

/// GitHub APIリポジトリのユニットテスト。
///
/// - 正常系と異常系の両方をテストする
/// - Mockを使用してAPI呼び出しをシミュレートする
@GenerateNiceMocks([MockSpec<Dio>()])
void main() {
  // テストで利用する定数を定義
  const baseUrl = 'https://api.github.com';

  // 前処理(テスト前に1回呼ばれる)
  setUpAll(Logger.configure);

  // 正常系テストケースを定義
  group('[正常系] GithubApiRepository オフラインテスト', () {
    late final MockDio mockDio;

    // 前処理(テスト前に1回呼ばれる)
    setUpAll(() {
      mockDio = MockDio();
    });

    // 後処理(テスト後に毎回呼ばれる)
    tearDown(() {
      reset(mockDio); // セットされたデータを初期化するためにモックをリセットする
    });

    test(
      'ユーザーリスト取得APIのレスポンス結果が正しいこと',
      () async {
        // Mockにデータをセット
        when(mockDio.options).thenReturn(dioDefaultOptions);
        when(mockDio.fetch<List<dynamic>>(any)).thenAnswer(
          (_) async => Response(
            data: json.decode(_userListData) as List<dynamic>,
            requestOptions: RequestOptions(path: '/users'),
          ),
        );

        // ProviderにMockをセット
        final container = createContainer(
          overrides: [
            githubApiClientProvider.overrideWithValue(
              GithubApiClient(mockDio, baseUrl: baseUrl),
            ),
          ],
        );

        // テスト実施
        final result = await container
            .read(githubApiRepositoryProvider)
            .fetchUsers(since: 0, perPage: 20);

        // テスト結果を検証
        expect(result.length, 2); // 実施結果と期待値が一致していること
        verify(mockDio.fetch<Map<String, dynamic>>(any))
            .called(1); // 注入したMockの関数が1回呼ばれていること
      },
    );
  });

  // 異常系テストケースを定義
  group('[異常系] GithubApiRepository オフラインテスト', () {
    late final MockDio mockDio;

    // 前処理(テスト前に1回呼ばれる)
    setUpAll(() {
      mockDio = MockDio();
    });

    // 後処理(テスト後に毎回呼ばれる)
    tearDown(() {
      reset(mockDio); // セットされたデータを初期化するためにモックをリセットする
    });

    test(
      'ユーザーリスト取得APIでエラーが発生した場合、AppExceptionが発生すること',
      () async {
        /// Mockにデータをセットする
        when(mockDio.options).thenReturn(dioDefaultOptions);
        final requestOption = RequestOptions(path: '/users');
        when(mockDio.fetch<List<dynamic>>(any)).thenThrow(
          DioException(
            requestOptions: requestOption,
            response: Response(
              statusCode: 400,
              requestOptions: requestOption,
            ),
            message: 'error',
          ),
        );

        // ProviderにMockをセット
        final container = createContainer(
          overrides: [
            githubApiClientProvider.overrideWithValue(
              GithubApiClient(mockDio, baseUrl: baseUrl),
            ),
          ],
        );

        // テスト実施
        try {
          await container
              .read(githubApiRepositoryProvider)
              .fetchUsers(since: 0, perPage: 20);
          fail('failed');
        } on AppException catch (e) {
          // テスト結果を検証
          expect(e.title, 'error'); // エラーメッセージが期待値であること
          verify(mockDio.fetch<Map<String, dynamic>>(any))
              .called(1); // 注入したMockの関数が1回呼ばれていること
        } on Exception catch (_) {
          fail('failed');
        }
      },
    );
  });
}

/// ダミーデータ(jsonをStringで管理)
const _userListData = '''
[
  {
    "login": "mojombo", 
    "id": 1,
    "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", 
    "url": "https://api.github.com/users/mojombo"
  },
  {
    "login": "hukusuke1007", 
    "id": 2,
    "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", 
    "url": "https://api.github.com/users/hukusuke1007"
  }
]
''';

※ 自分のリポジトリ作成した後のファイルであるため、編集している部分があります

GitHub API リポジトリの動作を確認し、正常系と異常系の両方で期待通りに動作することを確認しています。各テストケースでは、Mock オブジェクトを使用して DI(依存性注入)し、実際の API 呼び出しを行わずにテストを行っています。

※ DI(依存性注入)とは、ソフトウェア開発の設計パターンの一つで、オブジェクトの生成や依存関係の管理を外部から注入することで、コードの柔軟性やテストのしやすさを向上させる手法です

モック

モックを使用することで、外部依存関係を再現し、テストの際に独立した環境を作成できます。mockito パッケージと、Riverpod パッケージの overrides 機能を利用してモックを DI します。

Riverpod パッケージの overrides 機能とは、Riverpod のプロバイダをテスト用に上書きする機能で、テスト時にモックオブジェクトを注入するために使用されます。

使用方法

mockito パッケージを利用するため、以下のように依存関係を追加します。

確認:pubspec.yaml
dev_dependencies:
  mockito: 5.4.4

モックの DI には、Riverpod パッケージの overrides 機能を使用します。

以下は該当のコードです。

確認:test/features/github_users/repositories/github_api_repository_test.dart
// ProviderにMockをセット
final container = createContainer(
  overrides: [
    githubApiClientProvider.overrideWithValue(
      GithubApiClient(mockDio, baseUrl: baseUrl),
    ),
  ],
);

2. ウィジェットテスト Widget tests

ウィジェットテスト(他の UI フレームワークでいうコンポーネントテスト)は、単一のウィジェットをテストします。UI の要素が期待通りに表示され、動作するかを確認します。

ウィジェットとは、Flutter で UI を構築する基本的な要素で、ボタンやテキストフィールドなどの視覚要素や、レイアウトのためのコンテナなどを指します。

実行方法

すべてのウィジェットテストを実行するには、ユニットテスト同様、以下のコマンドを使用します。

flutter test

特定のテストファイルを実行するには、ファイルのパスを指定します。

flutter test test/features/github_users/pages/github_users_page_test.dart

実行結果

ウィジェットテストでも、特定のテストファイルを試しに実行してみます。

実行結果
# r_yamate @ mbp in ~/development/picture_book_log on git:feature/search x [9:54:49] 
$ flutter test test/features/github_users/pages/github_users_page_test.dart
00:05 +0: [正常系] GithubUsersPage オフラインテスト (setUpAll)
00:05 +0: [正常系] GithubUsersPage オフラインテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること
00:06 +0: [正常系] GithubUsersPage オフラインテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること
00:07 +0: [正常系] GithubUsersPage オフラインテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること
00:08 +0: [正常系] GithubUsersPage オフラインテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること
00:08 +1: [正常系] GithubUsersPage オフラインテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること
00:08 +1: [正常系] GithubUsersPage オフラインテスト (tearDownAll)
00:08 +1: All tests passed!  

実行されたテストコードの確認

該当の実行されたウィジェットテストのコードを確認します。

確認:test/features/github_users/pages/github_users_page_test.dart
// ignore_for_file: scoped_providers_should_specify_dependencies
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
import 'package:yomikey/core/utils/logger.dart';
import 'package:yomikey/features/github_users/entities/user.dart';
import 'package:yomikey/features/github_users/pages/github_users_page.dart';
import 'package:yomikey/features/github_users/repositories/github_api_repository.dart';

import 'github_users_page_test.mocks.dart';

/// Widget tests
/// https://docs.flutter.dev/testing#widget-tests
/// https://docs.flutter.dev/cookbook/testing/unit/mocking
@GenerateNiceMocks(
  [MockSpec<GithubApiRepository>()],
)
void main() {
  // 前処理(テスト前に1回呼ばれる)
  setUpAll(Logger.configure);

  // 正常系テストケース
  group('[正常系] GithubUsersPage オフラインテスト', () {
    late final MockGithubApiRepository mockGithubApiRepository;

    // 前処理(テスト前に1回呼ばれる)
    setUpAll(() {
      mockGithubApiRepository = MockGithubApiRepository();
    });

    // 後処理(テスト後に毎回呼ばれる)
    tearDown(() {
      reset(mockGithubApiRepository); // セットされたデータを初期化するためにモックをリセット
    });

    testWidgets('ユーザーリストを一番下までスクロールして、期待する情報が表示されること', (tester) async {
      // Mockにデータをセット
      when(mockGithubApiRepository.fetchUsers(since: 0, perPage: 20))
          .thenAnswer((_) async {
        return List.generate(20, (index) {
          final id = index;
          return User(
            login: 'User $id',
            id: id,
            url: 'https://example/$id',
          );
        });
      });
      when(mockGithubApiRepository.fetchUsers(since: 19, perPage: 20))
          .thenAnswer((_) async {
        return List.generate(20, (index) {
          final id = index + 20;
          return User(
            login: 'User $id',
            id: id,
            url: 'https://example/$id',
          );
        });
      }); // ページング取得用にセット

      // Widgetを構築
      await tester.pumpWidget(
        ProviderScope(
          overrides: [
            // ProviderにMockをセット
            githubApiRepositoryProvider.overrideWithValue(
              mockGithubApiRepository,
            ),
          ],
          child: const MaterialApp(
            home: GithubUsersPage(),
          ),
        ),
      );
      await tester.pump(); // Providerの非同期buildが処理されるので、処理後の状態を反映

      // テスト実施(初回表示)
      final listFinder = find.byType(Scrollable);
      final item1Finder = find.text('User 19'); // リストの最後の情報を見つける
      await tester.scrollUntilVisible(
        item1Finder,
        500,
        scrollable: listFinder,
      );
      expect(item1Finder, findsOneWidget); // 期待する状態のWidgetが1つ見つかること

      // テスト実施(ページング後)
      await tester.drag(
        find.byType(SmartRefresher),
        const Offset(100, 0),
      ); // 上にスワイプ
      await tester.pumpAndSettle(); // ページング処理のアニメーションが終わるまで待ち、処理後の状態を反映
      final item2Finder = find.text('User 39'); // ページング処理後、リストの最後の情報を見つける
      await tester.scrollUntilVisible(
        item2Finder,
        500,
        scrollable: listFinder,
      );
      expect(item2Finder, findsOneWidget); // 期待する状態のWidgetが1つ見つかること
    });
  });
}

リストをスクロールするテストについては、公式ドキュメントにも例が載っています。

3. 統合テスト Integration tests

統合テスト は、アプリ全体の機能を実際のデバイスやシミュレータで確認するためのテストです。アプリが期待通りに動作し、異なるコンポーネント間のやり取りが正常に行われるかを検証します。

実行方法

すべての統合テストを実行するには、以下のコマンドを使用します。

flutter test --dart-define=FLAVOR=dev integration_test

実機やシミュレータを起動させる必要があるため、--dart-define オプションで flavor 設定を指定します。flavor 設定とは、異なる環境(開発、ステージング、本番)でアプリをビルドするための設定で、API エンドポイントやデバッグフラグなどを変更できます。

特定のテストファイルを実行するには、ファイルのパスを指定します。

flutter test --dart-define=FLAVOR=dev integration_test/features/github_users/pages/github_users_page_test.dart

統合テストのファイルは integration_test/ ディレクトリ以下に配置されています。

実行結果

特定のテストファイルを実行してみます。

実行結果
実行結果
# r_yamate @ mbp in ~/development/picture_book_log on git:feature/search x [18:18:06] 
$ flutter test --dart-define=FLAVOR=dev integration_test/features/github_users/pages/github_users_page_test.dart
00:00 +0: loading /Users/r_yamate/development/picture_book_log/integration_test/features/github_users/pages/github_users_page_test.dart              R00:43 +0: loading /Users/r_yamate/development/picture_book_log/integration_test/features/github_users/pages/github_users_page_test.dart         42.6s
✓  Built build/app/outputs/flutter-apk/app-debug.apk.
00:51 +0: loading /Users/r_yamate/development/picture_book_log/integration_test/features/github_users/pages/github_users_page_test.dart              I01:38 +0: loading /Users/r_yamate/development/picture_book_log/integration_test/features/github_users/pages/github_users_page_test.dart         46.7s
01:43 +0: [正常系] GithubUsersPage E2Eテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること                                   01:44 +0: [正常系] GithubUsersPage E2Eテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること                                   01:44 +0: [正常系] GithubUsersPage E2Eテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること                                                                           
👻 INFO 2024-07-14 18:21:46.313656 [package:yomikey/main.dart 56:10 in main] FlavorType.dev
👻 INFO 2024-07-14 18:21:47.296424 [package:yomikey/core/router/transition_observer.dart 69:12 in TransitionObserver._onTransition] pageName: null, push
👻 INFO 2024-07-14 18:21:47.662809 [package:yomikey/features/start_up/use_cases/start_up.dart 18:10 in startUp] LoginType.anonymously
👻 INFO 2024-07-14 18:21:48.212754 [package:yomikey/core/router/transition_observer.dart 69:12 in TransitionObserver._onTransition] pageName: null, push
👻 INFO 2024-07-14 18:21:49.023837 [package:yomikey/features/setting/use_cases/fetch_my_profile.dart 23:12 in FetchMyProfile.build] userId: xvjS5Z1gv3PB55IM7z1F53HI6qA3
*** Request ***
uri: https://api.github.com/users?since=0&per_page=20
method: GET
responseType: ResponseType.json
followRedirects: true
persistentConnection: true
connectTimeout: 0:00:30.000000
sendTimeout: 0:00:30.000000
receiveTimeout: 0:00:30.000000
receiveDataWhenStatusError: true
extra: {}
headers:
 Content-Type: application/json
data:
null

*** Response ***
uri: https://api.github.com/users?since=0&per_page=20
statusCode: 200
headers:
 date: Sun, 14 Jul 2024 09:21:50 GMT
 x-ratelimit-limit: 60
 x-ratelimit-reset: 1720952387
 vary: Accept,Accept-Encoding, Accept, X-Requested-With
 content-encoding: gzip
 access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
 x-ratelimit-remaining: 58
 server: github.com
 x-ratelimit-used: 2
 x-github-request-id: A02A:1D98D1:C96C61:D5D224:669398AE
 accept-ranges: bytes
 content-length: 1508
 etag: W/"45e4689c1b110930e1d68abe1877e1b0eda4b057a5c200f2904cea726f3cae48"
 x-frame-options: deny
 content-security-policy: default-src 'none'
 cache-control: public, max-age=60, s-maxage=60
 access-control-allow-origin: *
 strict-transport-security: max-age=31536000; includeSubdomains; preload
 referrer-policy: origin-when-cross-origin, strict-origin-when-cross-origin
 content-type: application/json; charset=utf-8
 x-xss-protection: 0
 link: <https://api.github.com/users?since=30&per_page=20>; rel="next", <https://api.github.com/users{?since}>; rel="first"
 x-github-api-version-selected: 2022-11-28
 x-ratelimit-resource: core
 x-content-type-options: nosniff
 x-github-media-type: github.v3; format=json
Response Text:
[{login: mojombo, id: 1, node_id: MDQ6VXNlcjE=, avatar_url: https://avatars.githubusercontent.com/u/1?v=4, gravatar_id: , url: https://api.github.com/users/mojombo, html_url: https://github.com/mojombo, followers_url: https://api.github.com/users/mojombo/followers, following_url: https://api.github.com/users/mojombo/following{/other_user}, gists_url: https://api.github.com/users/mojombo/gists{/gist_id}, starred_url: https://api.github.com/users/mojombo/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/mojombo/subscriptions, organizations_url: https://api.github.com/users/mojombo/orgs, repos_url: https://api.github.com/users/mojombo/repos, events_url: https://api.github.com/users/mojombo/events{/privacy}, received_events_url: https://api.github.com/users/mojombo/received_events, type: User, site_admin: false}, {login: defunkt, id: 2, node_id: MDQ6VXNlcjI=, avatar_url: https://avatars.githubusercontent.com/u/2?v=4, gravatar_id: , url: https://api.github.com/users/defunkt, html_url: https://github.com/defunkt, followers_url: https://api.github.com/users/defunkt/followers, following_url: https://api.github.com/users/defunkt/following{/other_user}, gists_url: https://api.github.com/users/defunkt/gists{/gist_id}, starred_url: https://api.github.com/users/defunkt/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/defunkt/subscriptions, organizations_url: https://api.github.com/users/defunkt/orgs, repos_url: https://api.github.com/users/defunkt/repos, events_url: https://api.github.com/users/defunkt/events{/privacy}, received_events_url: https://api.github.com/users/defunkt/received_events, type: User, site_admin: false}, {login: pjhyett, id: 3, node_id: MDQ6VXNlcjM=, avatar_url: https://avatars.githubusercontent.com/u/3?v=4, gravatar_id: , url: https://api.github.com/users/pjhyett, html_url: https://github.com/pjhyett, followers_url: https://api.github.com/users/pjhyett/followers, following_url: https://api.github.com/users/pjhyett/following{/other_user}, gists_url: https://api.github.com/users/pjhyett/gists{/gist_id}, starred_url: https://api.github.com/users/pjhyett/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/pjhyett/subscriptions, organizations_url: https://api.github.com/users/pjhyett/orgs, repos_url: https://api.github.com/users/pjhyett/repos, events_url: https://api.github.com/users/pjhyett/events{/privacy}, received_events_url: https://api.github.com/users/pjhyett/received_events, type: User, site_admin: false}, {login: wycats, id: 4, node_id: MDQ6VXNlcjQ=, avatar_url: https://avatars.githubusercontent.com/u/4?v=4, gravatar_id: , url: https://api.github.com/users/wycats, html_url: https://github.com/wycats, followers_url: https://api.github.com/users/wycats/followers, following_url: https://api.github.com/users/wycats/following{/other_user}, gists_url: https://api.github.com/users/wycats/gists{/gist_id}, starred_url: https://api.github.com/users/wycats/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/wycats/subscriptions, organizations_url: https://api.github.com/users/wycats/orgs, repos_url: https://api.github.com/users/wycats/repos, events_url: https://api.github.com/users/wycats/events{/privacy}, received_events_url: https://api.github.com/users/wycats/received_events, type: User, site_admin: false}, {login: ezmobius, id: 5, node_id: MDQ6VXNlcjU=, avatar_url: https://avatars.githubusercontent.com/u/5?v=4, gravatar_id: , url: https://api.github.com/users/ezmobius, html_url: https://github.com/ezmobius, followers_url: https://api.github.com/users/ezmobius/followers, following_url: https://api.github.com/users/ezmobius/following{/other_user}, gists_url: https://api.github.com/users/ezmobius/gists{/gist_id}, starred_url: https://api.github.com/users/ezmobius/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/ezmobius/subscriptions, organizations_url: https://api.github.com/users/ezmobius/orgs, repos_url: https://api.github.com/users/ezmobius/repos, events_url: https://api.github.com/users/ezmobius/events{/privacy}, received_events_url: https://api.github.com/users/ezmobius/received_events, type: User, site_admin: false}, {login: ivey, id: 6, node_id: MDQ6VXNlcjY=, avatar_url: https://avatars.githubusercontent.com/u/6?v=4, gravatar_id: , url: https://api.github.com/users/ivey, html_url: https://github.com/ivey, followers_url: https://api.github.com/users/ivey/followers, following_url: https://api.github.com/users/ivey/following{/other_user}, gists_url: https://api.github.com/users/ivey/gists{/gist_id}, starred_url: https://api.github.com/users/ivey/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/ivey/subscriptions, organizations_url: https://api.github.com/users/ivey/orgs, repos_url: https://api.github.com/users/ivey/repos, events_url: https://api.github.com/users/ivey/events{/privacy}, received_events_url: https://api.github.com/users/ivey/received_events, type: User, site_admin: false}, {login: evanphx, id: 7, node_id: MDQ6VXNlcjc=, avatar_url: https://avatars.githubusercontent.com/u/7?v=4, gravatar_id: , url: https://api.github.com/users/evanphx, html_url: https://github.com/evanphx, followers_url: https://api.github.com/users/evanphx/followers, following_url: https://api.github.com/users/evanphx/following{/other_user}, gists_url: https://api.github.com/users/evanphx/gists{/gist_id}, starred_url: https://api.github.com/users/evanphx/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/evanphx/subscriptions, organizations_url: https://api.github.com/users/evanphx/orgs, repos_url: https://api.github.com/users/evanphx/repos, events_url: https://api.github.com/users/evanphx/events{/privacy}, received_events_url: https://api.github.com/users/evanphx/received_events, type: User, site_admin: false}, {login: vanpelt, id: 17, node_id: MDQ6VXNlcjE3, avatar_url: https://avatars.githubusercontent.com/u/17?v=4, gravatar_id: , url: https://api.github.com/users/vanpelt, html_url: https://github.com/vanpelt, followers_url: https://api.github.com/users/vanpelt/followers, following_url: https://api.github.com/users/vanpelt/following{/other_user}, gists_url: https://api.github.com/users/vanpelt/gists{/gist_id}, starred_url: https://api.github.com/users/vanpelt/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/vanpelt/subscriptions, organizations_url: https://api.github.com/users/vanpelt/orgs, repos_url: https://api.github.com/users/vanpelt/repos, events_url: https://api.github.com/users/vanpelt/events{/privacy}, received_events_url: https://api.github.com/users/vanpelt/received_events, type: User, site_admin: false}, {login: wayneeseguin, id: 18, node_id: MDQ6VXNlcjE4, avatar_url: https://avatars.githubusercontent.com/u/18?v=4, gravatar_id: , url: https://api.github.com/users/wayneeseguin, html_url: https://github.com/wayneeseguin, followers_url: https://api.github.com/users/wayneeseguin/followers, following_url: https://api.github.com/users/wayneeseguin/following{/other_user}, gists_url: https://api.github.com/users/wayneeseguin/gists{/gist_id}, starred_url: https://api.github.com/users/wayneeseguin/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/wayneeseguin/subscriptions, organizations_url: https://api.github.com/users/wayneeseguin/orgs, repos_url: https://api.github.com/users/wayneeseguin/repos, events_url: https://api.github.com/users/wayneeseguin/events{/privacy}, received_events_url: https://api.github.com/users/wayneeseguin/received_events, type: User, site_admin: false}, {login: brynary, id: 19, node_id: MDQ6VXNlcjE5, avatar_url: https://avatars.githubusercontent.com/u/19?v=4, gravatar_id: , url: https://api.github.com/users/brynary, html_url: https://github.com/brynary, followers_url: https://api.github.com/users/brynary/followers, following_url: https://api.github.com/users/brynary/following{/other_user}, gists_url: https://api.github.com/users/brynary/gists{/gist_id}, starred_url: https://api.github.com/users/brynary/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/brynary/subscriptions, organizations_url: https://api.github.com/users/brynary/orgs, repos_url: https://api.github.com/users/brynary/repos, events_url: https://api.github.com/users/brynary/events{/privacy}, received_events_url: https://api.github.com/users/brynary/received_events, type: User, site_admin: false}, {login: kevinclark, id: 20, node_id: MDQ6VXNlcjIw, avatar_url: https://avatars.githubusercontent.com/u/20?v=4, gravatar_id: , url: https://api.github.com/users/kevinclark, html_url: https://github.com/kevinclark, followers_url: https://api.github.com/users/kevinclark/followers, following_url: https://api.github.com/users/kevinclark/following{/other_user}, gists_url: https://api.github.com/users/kevinclark/gists{/gist_id}, starred_url: https://api.github.com/users/kevinclark/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/kevinclark/subscriptions, organizations_url: https://api.github.com/users/kevinclark/orgs, repos_url: https://api.github.com/users/kevinclark/repos, events_url: https://api.github.com/users/kevinclark/events{/privacy}, received_events_url: https://api.github.com/users/kevinclark/received_events, type: User, site_admin: false}, {login: technoweenie, id: 21, node_id: MDQ6VXNlcjIx, avatar_url: https://avatars.githubusercontent.com/u/21?v=4, gravatar_id: , url: https://api.github.com/users/technoweenie, html_url: https://github.com/technoweenie, followers_url: https://api.github.com/users/technoweenie/followers, following_url: https://api.github.com/users/technoweenie/following{/other_user}, gists_url: https://api.github.com/users/technoweenie/gists{/gist_id}, starred_url: https://api.github.com/users/technoweenie/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/technoweenie/subscriptions, organizations_url: https://api.github.com/users/technoweenie/orgs, repos_url: https://api.github.com/users/technoweenie/repos, events_url: https://api.github.com/users/technoweenie/events{/privacy}, received_events_url: https://api.github.com/users/technoweenie/received_events, type: User, site_admin: false}, {login: macournoyer, id: 22, node_id: MDQ6VXNlcjIy, avatar_url: https://avatars.githubusercontent.com/u/22?v=4, gravatar_id: , url: https://api.github.com/users/macournoyer, html_url: https://github.com/macournoyer, followers_url: https://api.github.com/users/macournoyer/followers, following_url: https://api.github.com/users/macournoyer/following{/other_user}, gists_url: https://api.github.com/users/macournoyer/gists{/gist_id}, starred_url: https://api.github.com/users/macournoyer/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/macournoyer/subscriptions, organizations_url: https://api.github.com/users/macournoyer/orgs, repos_url: https://api.github.com/users/macournoyer/repos, events_url: https://api.github.com/users/macournoyer/events{/privacy}, received_events_url: https://api.github.com/users/macournoyer/received_events, type: User, site_admin: false}, {login: takeo, id: 23, node_id: MDQ6VXNlcjIz, avatar_url: https://avatars.githubusercontent.com/u/23?v=4, gravatar_id: , url: https://api.github.com/users/takeo, html_url: https://github.com/takeo, followers_url: https://api.github.com/users/takeo/followers, following_url: https://api.github.com/users/takeo/following{/other_user}, gists_url: https://api.github.com/users/takeo/gists{/gist_id}, starred_url: https://api.github.com/users/takeo/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/takeo/subscriptions, organizations_url: https://api.github.com/users/takeo/orgs, repos_url: https://api.github.com/users/takeo/repos, events_url: https://api.github.com/users/takeo/events{/privacy}, received_events_url: https://api.github.com/users/takeo/received_events, type: User, site_admin: false}, {login: caged, id: 25, node_id: MDQ6VXNlcjI1, avatar_url: https://avatars.githubusercontent.com/u/25?v=4, gravatar_id: , url: https://api.github.com/users/caged, html_url: https://github.com/caged, followers_url: https://api.github.com/users/caged/followers, following_url: https://api.github.com/users/caged/following{/other_user}, gists_url: https://api.github.com/users/caged/gists{/gist_id}, starred_url: https://api.github.com/users/caged/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/caged/subscriptions, organizations_url: https://api.github.com/users/caged/orgs, repos_url: https://api.github.com/users/caged/repos, events_url: https://api.github.com/users/caged/events{/privacy}, received_events_url: https://api.github.com/users/caged/received_events, type: User, site_admin: false}, {login: topfunky, id: 26, node_id: MDQ6VXNlcjI2, avatar_url: https://avatars.githubusercontent.com/u/26?v=4, gravatar_id: , url: https://api.github.com/users/topfunky, html_url: https://github.com/topfunky, followers_url: https://api.github.com/users/topfunky/followers, following_url: https://api.github.com/users/topfunky/following{/other_user}, gists_url: https://api.github.com/users/topfunky/gists{/gist_id}, starred_url: https://api.github.com/users/topfunky/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/topfunky/subscriptions, organizations_url: https://api.github.com/users/topfunky/orgs, repos_url: https://api.github.com/users/topfunky/repos, events_url: https://api.github.com/users/topfunky/events{/privacy}, received_events_url: https://api.github.com/users/topfunky/received_events, type: User, site_admin: false}, {login: anotherjesse, id: 27, node_id: MDQ6VXNlcjI3, avatar_url: https://avatars.githubusercontent.com/u/27?v=4, gravatar_id: , url: https://api.github.com/users/anotherjesse, html_url: https://github.com/anotherjesse, followers_url: https://api.github.com/users/anotherjesse/followers, following_url: https://api.github.com/users/anotherjesse/following{/other_user}, gists_url: https://api.github.com/users/anotherjesse/gists{/gist_id}, starred_url: https://api.github.com/users/anotherjesse/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/anotherjesse/subscriptions, organizations_url: https://api.github.com/users/anotherjesse/orgs, repos_url: https://api.github.com/users/anotherjesse/repos, events_url: https://api.github.com/users/anotherjesse/events{/privacy}, received_events_url: https://api.github.com/users/anotherjesse/received_events, type: User, site_admin: false}, {login: roland, id: 28, node_id: MDQ6VXNlcjI4, avatar_url: https://avatars.githubusercontent.com/u/28?v=4, gravatar_id: , url: https://api.github.com/users/roland, html_url: https://github.com/roland, followers_url: https://api.github.com/users/roland/followers, following_url: https://api.github.com/users/roland/following{/other_user}, gists_url: https://api.github.com/users/roland/gists{/gist_id}, starred_url: https://api.github.com/users/roland/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/roland/subscriptions, organizations_url: https://api.github.com/users/roland/orgs, repos_url: https://api.github.com/users/roland/repos, events_url: https://api.github.com/users/roland/events{/privacy}, received_events_url: https://api.github.com/users/roland/received_events, type: User, site_admin: false}, {login: lukas, id: 29, node_id: MDQ6VXNlcjI5, avatar_url: https://avatars.githubusercontent.com/u/29?v=4, gravatar_id: , url: https://api.github.com/users/lukas, html_url: https://github.com/lukas, followers_url: https://api.github.com/users/lukas/followers, following_url: https://api.github.com/users/lukas/following{/other_user}, gists_url: https://api.github.com/users/lukas/gists{/gist_id}, starred_url: https://api.github.com/users/lukas/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/lukas/subscriptions, organizations_url: https://api.github.com/users/lukas/orgs, repos_url: https://api.github.com/users/lukas/repos, events_url: https://api.github.com/users/lukas/events{/privacy}, received_events_url: https://api.github.com/users/lukas/received_events, type: User, site_admin: false}, {login: fanvsfan, id: 30, node_id: MDQ6VXNlcjMw, avatar_url: https://avatars.githubusercontent.com/u/30?v=4, gravatar_id: , url: https://api.github.com/users/fanvsfan, html_url: https://github.com/fanvsfan, followers_url: https://api.github.com/users/fanvsfan/followers, following_url: https://api.github.com/users/fanvsfan/following{/other_user}, gists_url: https://api.github.com/users/fanvsfan/gists{/gist_id}, starred_url: https://api.github.com/users/fanvsfan/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/fanvsfan/subscriptions, organizations_url: https://api.github.com/users/fanvsfan/orgs, repos_url: https://api.github.com/users/fanvsfan/repos, events_url: https://api.github.com/users/fanvsfan/events{/privacy}, received_events_url: https://api.github.com/users/fanvsfan/received_events, type: User, site_admin: false}]

*** Request ***
uri: https://api.github.com/users?since=30&per_page=20
method: GET
responseType: ResponseType.json
followRedirects: true
persistentConnection: true
connectTimeout: 0:00:30.000000
sendTimeout: 0:00:30.000000
receiveTimeout: 0:00:30.000000
receiveDataWhenStatusError: true
extra: {}
headers:
 Content-Type: application/json
data:
null

*** Response ***
uri: https://api.github.com/users?since=30&per_page=20
statusCode: 200
headers:
 date: Sun, 14 Jul 2024 09:21:54 GMT
 x-ratelimit-limit: 60
 x-ratelimit-reset: 1720952387
 transfer-encoding: chunked
 vary: Accept,Accept-Encoding, Accept, X-Requested-With
 content-encoding: gzip
 access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
 x-ratelimit-remaining: 57
 server: github.com
 x-ratelimit-used: 3
 x-github-request-id: A034:14FF95:14E7B9:161436:669398B2
 accept-ranges: bytes
 etag: W/"0d9b12bce7412d34073274367241ce8fd61f3c6b21b80cca365e81c1cf1699fd"
 x-frame-options: deny
 content-security-policy: default-src 'none'
 cache-control: public, max-age=60, s-maxage=60
 access-control-allow-origin: *
 strict-transport-security: max-age=31536000; includeSubdomains; preload
 referrer-policy: origin-when-cross-origin, strict-origin-when-cross-origin
 content-type: application/json; charset=utf-8
 x-xss-protection: 0
 link: <https://api.github.com/users?since=70&per_page=20>; rel="next", <https://api.github.com/users{?since}>; rel="first"
 x-github-api-version-selected: 2022-11-28
 x-ratelimit-resource: core
 x-content-type-options: nosniff
 x-github-media-type: github.v3; format=json
Response Text:
[{login: tomtt, id: 31, node_id: MDQ6VXNlcjMx, avatar_url: https://avatars.githubusercontent.com/u/31?v=4, gravatar_id: , url: https://api.github.com/users/tomtt, html_url: https://github.com/tomtt, followers_url: https://api.github.com/users/tomtt/followers, following_url: https://api.github.com/users/tomtt/following{/other_user}, gists_url: https://api.github.com/users/tomtt/gists{/gist_id}, starred_url: https://api.github.com/users/tomtt/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/tomtt/subscriptions, organizations_url: https://api.github.com/users/tomtt/orgs, repos_url: https://api.github.com/users/tomtt/repos, events_url: https://api.github.com/users/tomtt/events{/privacy}, received_events_url: https://api.github.com/users/tomtt/received_events, type: User, site_admin: false}, {login: railsjitsu, id: 32, node_id: MDQ6VXNlcjMy, avatar_url: https://avatars.githubusercontent.com/u/32?v=4, gravatar_id: , url: https://api.github.com/users/railsjitsu, html_url: https://github.com/railsjitsu, followers_url: https://api.github.com/users/railsjitsu/followers, following_url: https://api.github.com/users/railsjitsu/following{/other_user}, gists_url: https://api.github.com/users/railsjitsu/gists{/gist_id}, starred_url: https://api.github.com/users/railsjitsu/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/railsjitsu/subscriptions, organizations_url: https://api.github.com/users/railsjitsu/orgs, repos_url: https://api.github.com/users/railsjitsu/repos, events_url: https://api.github.com/users/railsjitsu/events{/privacy}, received_events_url: https://api.github.com/users/railsjitsu/received_events, type: User, site_admin: false}, {login: nitay, id: 34, node_id: MDQ6VXNlcjM0, avatar_url: https://avatars.githubusercontent.com/u/34?v=4, gravatar_id: , url: https://api.github.com/users/nitay, html_url: https://github.com/nitay, followers_url: https://api.github.com/users/nitay/followers, following_url: https://api.github.com/users/nitay/following{/other_user}, gists_url: https://api.github.com/users/nitay/gists{/gist_id}, starred_url: https://api.github.com/users/nitay/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/nitay/subscriptions, organizations_url: https://api.github.com/users/nitay/orgs, repos_url: https://api.github.com/users/nitay/repos, events_url: https://api.github.com/users/nitay/events{/privacy}, received_events_url: https://api.github.com/users/nitay/received_events, type: User, site_admin: false}, {login: kevwil, id: 35, node_id: MDQ6VXNlcjM1, avatar_url: https://avatars.githubusercontent.com/u/35?v=4, gravatar_id: , url: https://api.github.com/users/kevwil, html_url: https://github.com/kevwil, followers_url: https://api.github.com/users/kevwil/followers, following_url: https://api.github.com/users/kevwil/following{/other_user}, gists_url: https://api.github.com/users/kevwil/gists{/gist_id}, starred_url: https://api.github.com/users/kevwil/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/kevwil/subscriptions, organizations_url: https://api.github.com/users/kevwil/orgs, repos_url: https://api.github.com/users/kevwil/repos, events_url: https://api.github.com/users/kevwil/events{/privacy}, received_events_url: https://api.github.com/users/kevwil/received_events, type: User, site_admin: false}, {login: KirinDave, id: 36, node_id: MDQ6VXNlcjM2, avatar_url: https://avatars.githubusercontent.com/u/36?v=4, gravatar_id: , url: https://api.github.com/users/KirinDave, html_url: https://github.com/KirinDave, followers_url: https://api.github.com/users/KirinDave/followers, following_url: https://api.github.com/users/KirinDave/following{/other_user}, gists_url: https://api.github.com/users/KirinDave/gists{/gist_id}, starred_url: https://api.github.com/users/KirinDave/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/KirinDave/subscriptions, organizations_url: https://api.github.com/users/KirinDave/orgs, repos_url: https://api.github.com/users/KirinDave/repos, events_url: https://api.github.com/users/KirinDave/events{/privacy}, received_events_url: https://api.github.com/users/KirinDave/received_events, type: User, site_admin: false}, {login: jamesgolick, id: 37, node_id: MDQ6VXNlcjM3, avatar_url: https://avatars.githubusercontent.com/u/37?v=4, gravatar_id: , url: https://api.github.com/users/jamesgolick, html_url: https://github.com/jamesgolick, followers_url: https://api.github.com/users/jamesgolick/followers, following_url: https://api.github.com/users/jamesgolick/following{/other_user}, gists_url: https://api.github.com/users/jamesgolick/gists{/gist_id}, starred_url: https://api.github.com/users/jamesgolick/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/jamesgolick/subscriptions, organizations_url: https://api.github.com/users/jamesgolick/orgs, repos_url: https://api.github.com/users/jamesgolick/repos, events_url: https://api.github.com/users/jamesgolick/events{/privacy}, received_events_url: https://api.github.com/users/jamesgolick/received_events, type: User, site_admin: false}, {login: atmos, id: 38, node_id: MDQ6VXNlcjM4, avatar_url: https://avatars.githubusercontent.com/u/38?v=4, gravatar_id: , url: https://api.github.com/users/atmos, html_url: https://github.com/atmos, followers_url: https://api.github.com/users/atmos/followers, following_url: https://api.github.com/users/atmos/following{/other_user}, gists_url: https://api.github.com/users/atmos/gists{/gist_id}, starred_url: https://api.github.com/users/atmos/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/atmos/subscriptions, organizations_url: https://api.github.com/users/atmos/orgs, repos_url: https://api.github.com/users/atmos/repos, events_url: https://api.github.com/users/atmos/events{/privacy}, received_events_url: https://api.github.com/users/atmos/received_events, type: User, site_admin: false}, {login: errfree, id: 44, node_id: MDEyOk9yZ2FuaXphdGlvbjQ0, avatar_url: https://avatars.githubusercontent.com/u/44?v=4, gravatar_id: , url: https://api.github.com/users/errfree, html_url: https://github.com/errfree, followers_url: https://api.github.com/users/errfree/followers, following_url: https://api.github.com/users/errfree/following{/other_user}, gists_url: https://api.github.com/users/errfree/gists{/gist_id}, starred_url: https://api.github.com/users/errfree/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/errfree/subscriptions, organizations_url: https://api.github.com/users/errfree/orgs, repos_url: https://api.github.com/users/errfree/repos, events_url: https://api.github.com/users/errfree/events{/privacy}, received_events_url: https://api.github.com/users/errfree/received_events, type: Organization, site_admin: false}, {login: mojodna, id: 45, node_id: MDQ6VXNlcjQ1, avatar_url: https://avatars.githubusercontent.com/u/45?v=4, gravatar_id: , url: https://api.github.com/users/mojodna, html_url: https://github.com/mojodna, followers_url: https://api.github.com/users/mojodna/followers, following_url: https://api.github.com/users/mojodna/following{/other_user}, gists_url: https://api.github.com/users/mojodna/gists{/gist_id}, starred_url: https://api.github.com/users/mojodna/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/mojodna/subscriptions, organizations_url: https://api.github.com/users/mojodna/orgs, repos_url: https://api.github.com/users/mojodna/repos, events_url: https://api.github.com/users/mojodna/events{/privacy}, received_events_url: https://api.github.com/users/mojodna/received_events, type: User, site_admin: false}, {login: bmizerany, id: 46, node_id: MDQ6VXNlcjQ2, avatar_url: https://avatars.githubusercontent.com/u/46?v=4, gravatar_id: , url: https://api.github.com/users/bmizerany, html_url: https://github.com/bmizerany, followers_url: https://api.github.com/users/bmizerany/followers, following_url: https://api.github.com/users/bmizerany/following{/other_user}, gists_url: https://api.github.com/users/bmizerany/gists{/gist_id}, starred_url: https://api.github.com/users/bmizerany/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/bmizerany/subscriptions, organizations_url: https://api.github.com/users/bmizerany/orgs, repos_url: https://api.github.com/users/bmizerany/repos, events_url: https://api.github.com/users/bmizerany/events{/privacy}, received_events_url: https://api.github.com/users/bmizerany/received_events, type: User, site_admin: false}, {login: jnewland, id: 47, node_id: MDQ6VXNlcjQ3, avatar_url: https://avatars.githubusercontent.com/u/47?v=4, gravatar_id: , url: https://api.github.com/users/jnewland, html_url: https://github.com/jnewland, followers_url: https://api.github.com/users/jnewland/followers, following_url: https://api.github.com/users/jnewland/following{/other_user}, gists_url: https://api.github.com/users/jnewland/gists{/gist_id}, starred_url: https://api.github.com/users/jnewland/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/jnewland/subscriptions, organizations_url: https://api.github.com/users/jnewland/orgs, repos_url: https://api.github.com/users/jnewland/repos, events_url: https://api.github.com/users/jnewland/events{/privacy}, received_events_url: https://api.github.com/users/jnewland/received_events, type: User, site_admin: false}, {login: joshknowles, id: 48, node_id: MDQ6VXNlcjQ4, avatar_url: https://avatars.githubusercontent.com/u/48?v=4, gravatar_id: , url: https://api.github.com/users/joshknowles, html_url: https://github.com/joshknowles, followers_url: https://api.github.com/users/joshknowles/followers, following_url: https://api.github.com/users/joshknowles/following{/other_user}, gists_url: https://api.github.com/users/joshknowles/gists{/gist_id}, starred_url: https://api.github.com/users/joshknowles/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/joshknowles/subscriptions, organizations_url: https://api.github.com/users/joshknowles/orgs, repos_url: https://api.github.com/users/joshknowles/repos, events_url: https://api.github.com/users/joshknowles/events{/privacy}, received_events_url: https://api.github.com/users/joshknowles/received_events, type: User, site_admin: false}, {login: hornbeck, id: 49, node_id: MDQ6VXNlcjQ5, avatar_url: https://avatars.githubusercontent.com/u/49?v=4, gravatar_id: , url: https://api.github.com/users/hornbeck, html_url: https://github.com/hornbeck, followers_url: https://api.github.com/users/hornbeck/followers, following_url: https://api.github.com/users/hornbeck/following{/other_user}, gists_url: https://api.github.com/users/hornbeck/gists{/gist_id}, starred_url: https://api.github.com/users/hornbeck/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/hornbeck/subscriptions, organizations_url: https://api.github.com/users/hornbeck/orgs, repos_url: https://api.github.com/users/hornbeck/repos, events_url: https://api.github.com/users/hornbeck/events{/privacy}, received_events_url: https://api.github.com/users/hornbeck/received_events, type: User, site_admin: false}, {login: jwhitmire, id: 50, node_id: MDQ6VXNlcjUw, avatar_url: https://avatars.githubusercontent.com/u/50?v=4, gravatar_id: , url: https://api.github.com/users/jwhitmire, html_url: https://github.com/jwhitmire, followers_url: https://api.github.com/users/jwhitmire/followers, following_url: https://api.github.com/users/jwhitmire/following{/other_user}, gists_url: https://api.github.com/users/jwhitmire/gists{/gist_id}, starred_url: https://api.github.com/users/jwhitmire/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/jwhitmire/subscriptions, organizations_url: https://api.github.com/users/jwhitmire/orgs, repos_url: https://api.github.com/users/jwhitmire/repos, events_url: https://api.github.com/users/jwhitmire/events{/privacy}, received_events_url: https://api.github.com/users/jwhitmire/received_events, type: User, site_admin: false}, {login: elbowdonkey, id: 51, node_id: MDQ6VXNlcjUx, avatar_url: https://avatars.githubusercontent.com/u/51?v=4, gravatar_id: , url: https://api.github.com/users/elbowdonkey, html_url: https://github.com/elbowdonkey, followers_url: https://api.github.com/users/elbowdonkey/followers, following_url: https://api.github.com/users/elbowdonkey/following{/other_user}, gists_url: https://api.github.com/users/elbowdonkey/gists{/gist_id}, starred_url: https://api.github.com/users/elbowdonkey/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/elbowdonkey/subscriptions, organizations_url: https://api.github.com/users/elbowdonkey/orgs, repos_url: https://api.github.com/users/elbowdonkey/repos, events_url: https://api.github.com/users/elbowdonkey/events{/privacy}, received_events_url: https://api.github.com/users/elbowdonkey/received_events, type: User, site_admin: false}, {login: reinh, id: 52, node_id: MDQ6VXNlcjUy, avatar_url: https://avatars.githubusercontent.com/u/52?v=4, gravatar_id: , url: https://api.github.com/users/reinh, html_url: https://github.com/reinh, followers_url: https://api.github.com/users/reinh/followers, following_url: https://api.github.com/users/reinh/following{/other_user}, gists_url: https://api.github.com/users/reinh/gists{/gist_id}, starred_url: https://api.github.com/users/reinh/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/reinh/subscriptions, organizations_url: https://api.github.com/users/reinh/orgs, repos_url: https://api.github.com/users/reinh/repos, events_url: https://api.github.com/users/reinh/events{/privacy}, received_events_url: https://api.github.com/users/reinh/received_events, type: User, site_admin: false}, {login: knzai, id: 53, node_id: MDQ6VXNlcjUz, avatar_url: https://avatars.githubusercontent.com/u/53?v=4, gravatar_id: , url: https://api.github.com/users/knzai, html_url: https://github.com/knzai, followers_url: https://api.github.com/users/knzai/followers, following_url: https://api.github.com/users/knzai/following{/other_user}, gists_url: https://api.github.com/users/knzai/gists{/gist_id}, starred_url: https://api.github.com/users/knzai/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/knzai/subscriptions, organizations_url: https://api.github.com/users/knzai/orgs, repos_url: https://api.github.com/users/knzai/repos, events_url: https://api.github.com/users/knzai/events{/privacy}, received_events_url: https://api.github.com/users/knzai/received_events, type: User, site_admin: false}, {login: bs, id: 68, node_id: MDQ6VXNlcjY4, avatar_url: https://avatars.githubusercontent.com/u/68?v=4, gravatar_id: , url: https://api.github.com/users/bs, html_url: https://github.com/bs, followers_url: https://api.github.com/users/bs/followers, following_url: https://api.github.com/users/bs/following{/other_user}, gists_url: https://api.github.com/users/bs/gists{/gist_id}, starred_url: https://api.github.com/users/bs/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/bs/subscriptions, organizations_url: https://api.github.com/users/bs/orgs, repos_url: https://api.github.com/users/bs/repos, events_url: https://api.github.com/users/bs/events{/privacy}, received_events_url: https://api.github.com/users/bs/received_events, type: User, site_admin: false}, {login: rsanheim, id: 69, node_id: MDQ6VXNlcjY5, avatar_url: https://avatars.githubusercontent.com/u/69?v=4, gravatar_id: , url: https://api.github.com/users/rsanheim, html_url: https://github.com/rsanheim, followers_url: https://api.github.com/users/rsanheim/followers, following_url: https://api.github.com/users/rsanheim/following{/other_user}, gists_url: https://api.github.com/users/rsanheim/gists{/gist_id}, starred_url: https://api.github.com/users/rsanheim/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/rsanheim/subscriptions, organizations_url: https://api.github.com/users/rsanheim/orgs, repos_url: https://api.github.com/users/rsanheim/repos, events_url: https://api.github.com/users/rsanheim/events{/privacy}, received_events_url: https://api.github.com/users/rsanheim/received_events, type: User, site_admin: false}, {login: schacon, id: 70, node_id: MDQ6VXNlcjcw, avatar_url: https://avatars.githubusercontent.com/u/70?v=4, gravatar_id: , url: https://api.github.com/users/schacon, html_url: https://github.com/schacon, followers_url: https://api.github.com/users/schacon/followers, following_url: https://api.github.com/users/schacon/following{/other_user}, gists_url: https://api.github.com/users/schacon/gists{/gist_id}, starred_url: https://api.github.com/users/schacon/starred{/owner}{/repo}, subscriptions_url: https://api.github.com/users/schacon/subscriptions, organizations_url: https://api.github.com/users/schacon/orgs, repos_url: https://api.github.com/users/schacon/repos, events_url: https://api.github.com/users/schacon/events{/privacy}, received_events_url: https://api.github.com/users/schacon/received_events, type: User, site_admin: false}]

01:56 +1: [正常系] GithubUsersPage E2Eテスト ユーザーリストを一番下までスクロールして、期待する情報が表示されること                                   01:57 +1: All tests passed!   

実行されたテストコードの確認

該当の実行された統合テストのコードを確認します。

確認:integration_test/features/github_users/pages/github_users_page_test.dart
import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
import 'package:yomikey/core/widgets/smart_refresher/smart_refresher_custom.dart';
import 'package:yomikey/main.dart' as app;

/// GithubUsersPageの統合テスト。
///
/// - 正常系のテストケースを含む
/// - メインアプリケーションの動作をシミュレートする
/// - リストのスクロールやページング機能をテストする
/// - UIの要素が期待通りに表示され、動作するかを検証する
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('[正常系] GithubUsersPage E2Eテスト', () {
    testWidgets('ユーザーリストを一番下までスクロールして、期待する情報が表示されること', (tester) async {
      // メイン画面が表示されるまで待つ
      app.main().ignore();
      await tester.pumpAndSettle();

      // Github Users画面へ移動
      await tester.tap(find.text('タブ2'));
      await tester.pumpAndSettle();

      // 現在表示されている状態を確認
      expect(
        find.text('Github Users'),
        findsOneWidget,
      ); // 期待する状態のWidgetが1つ見つかること

      // 下までスクロールする
      final listFinder = find.byType(Scrollable);
      await tester.scrollUntilVisible(
        find.byType(SmartRefreshFooter), // リストの最後の情報を見つける
        500,
        scrollable: listFinder,
      );
      await tester.pumpAndSettle(); // スクロールアニメーションが終わるまで待ち、処理後の状態を反映
      expect(
        ((tester.firstWidget(find.byType(SmartRefresher)) as SmartRefresher)
                .child! as ListView)
            .semanticChildCount,
        20,
      ); // リストの個数が期待値であること

      // ページング処理をし、さらに下までスクロール
      await tester.drag(
        find.byType(SmartRefresher),
        const Offset(100, 0),
      ); // 上にスワイプ
      await tester.pumpAndSettle(); // ページング処理のアニメーションが終わるまで待ち、処理後の状態を反映
      await tester.scrollUntilVisible(
        find.byType(SmartRefreshFooter), // リストの最後の情報を見つける
        500,
        scrollable: listFinder,
      );
      await tester.pumpAndSettle(); // スクロールアニメーションが終わるまで待ち、処理後の状態を反映
      expect(
        ((tester.firstWidget(find.byType(SmartRefresher)) as SmartRefresher)
                .child! as ListView)
            .semanticChildCount,
        40,
      ); // リストの個数が期待値であること
    });
  });
}

テスト実行中は実機でアプリが起動して、以下の動作が自動で実行されていました。

  • 「タブ2」がタップされる
  • Github ユーザーリストページが表示される
  • スクロールされ、ユーザーリストが 20 ユーザー追加で表示される

おわりに

Flutter のテスト(ユニットテスト、ウィジェットテスト、統合テスト)について、hukusuke1007 / flutter_app_template のコードリーディング、テストコマンド実行を通して確認しました。

1つ目の機能を実装する際には、テストコードを必須事項として実装しようと思います。

ありがとうございました。

やり残し

Flutter アプリテンプレートに関連して、以下を今後やっていこうと思います。

  • プラグインの確認
    • プラグインやコードがあるかもしれませんが、不要と分かった段階で削除しながら、開発を進めていこうと思います
  • 参考文献の通読
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?