From c271af18b734d047a9e27edce7aed19bfcc84816 Mon Sep 17 00:00:00 2001 From: Adam Dybcio Date: Sun, 5 Oct 2025 22:59:31 +0200 Subject: [PATCH] feat: Add unit tests for various blocs and data sources; update dependencies --- pubspec.lock | 10 +- pubspec.yaml | 4 + test/blocs/achievements_bloc_test.dart | 143 +++++++++++++++++ test/blocs/browse_bloc_test.dart | 68 ++++++++ test/blocs/quiz_cubit_test.dart | 83 ++++++++++ test/blocs/quiz_run_cubit_test.dart | 130 +++++++++++++++ test/blocs/theme_cubit_test.dart | 62 ++++++++ test/blocs/user_level_bloc_test.dart | 78 +++++++++ .../quiz_remote_data_source_test.dart | 148 ++++++++++++++++++ test/dummy_test.dart | 7 - 10 files changed, 725 insertions(+), 8 deletions(-) create mode 100644 test/blocs/achievements_bloc_test.dart create mode 100644 test/blocs/browse_bloc_test.dart create mode 100644 test/blocs/quiz_cubit_test.dart create mode 100644 test/blocs/quiz_run_cubit_test.dart create mode 100644 test/blocs/theme_cubit_test.dart create mode 100644 test/blocs/user_level_bloc_test.dart create mode 100644 test/data/datasources/quiz_remote_data_source_test.dart delete mode 100644 test/dummy_test.dart diff --git a/pubspec.lock b/pubspec.lock index 4f514f9..9d6f1fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -122,7 +122,7 @@ packages: source: hosted version: "2.0.7" fake_async: - dependency: transitive + dependency: "direct main" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -333,6 +333,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mocktail: + dependency: "direct main" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6b470d8..ffa4ecf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,10 @@ dependencies: # Splash flutter_native_splash: ^2.4.6 + # Tests + mocktail: ^1.0.4 + fake_async: ^1.3.3 + dev_dependencies: flutter_test: sdk: flutter diff --git a/test/blocs/achievements_bloc_test.dart b/test/blocs/achievements_bloc_test.dart new file mode 100644 index 0000000..854a62e --- /dev/null +++ b/test/blocs/achievements_bloc_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:quiz_master/features/achievements/presentation/blocs/achievements_bloc.dart'; +import 'package:quiz_master/utils/enums.dart'; + +class MockStorage extends Mock implements Storage {} + +void main() { + late Storage storage; + late AchievementsBloc bloc; + + setUp(() { + storage = MockStorage(); + when(() => storage.write(any(), any())).thenAnswer((_) async {}); + when(() => storage.read(any())).thenReturn(null); + HydratedBloc.storage = storage; + + bloc = AchievementsBloc(); + }); + + group('AchievementsBloc', () { + test('initial state is empty', () { + expect(bloc.state.unlockedLevels, {}); + expect(bloc.state.totalQuizzes, 0); + expect(bloc.state.totalCorrectAnswers, 0); + expect(bloc.state.currentLevel, 1); + expect(bloc.state.bestQuizTimeSeconds, 9999); + }); + + test('CheckAchievements unlocks leveling achievements correctly', () { + bloc.add( + CheckAchievements( + quizCount: 0, + correctAnswers: 0, + level: 5, + quizTimeSeconds: 999, + ), + ); + bloc.stream.listen( + expectAsync1((state) { + expect( + state.unlockedLevels[AchievementCategory.leveling], + AchievementLevel.bronze, + ); + }), + ); + }); + + test('CheckAchievements unlocks answerMaster achievements correctly', () { + bloc.add( + CheckAchievements( + quizCount: 0, + correctAnswers: 50, + level: 1, + quizTimeSeconds: 999, + ), + ); + bloc.stream.listen( + expectAsync1((state) { + expect( + state.unlockedLevels[AchievementCategory.answerMaster], + AchievementLevel.gold, + ); + }), + ); + }); + + test('CheckAchievements unlocks quizAbuser achievements correctly', () { + bloc.add( + CheckAchievements( + quizCount: 25, + correctAnswers: 0, + level: 1, + quizTimeSeconds: 999, + ), + ); + bloc.stream.listen( + expectAsync1((state) { + expect( + state.unlockedLevels[AchievementCategory.quizAbuser], + AchievementLevel.gold, + ); + }), + ); + }); + + test('CheckAchievements unlocks speedster achievements correctly', () { + bloc.add( + CheckAchievements( + quizCount: 0, + correctAnswers: 0, + level: 1, + quizTimeSeconds: 30, + ), + ); + bloc.stream.listen( + expectAsync1((state) { + expect( + state.unlockedLevels[AchievementCategory.speedster], + AchievementLevel.platinum, + ); + }), + ); + }); + + test('CheckAchievements updates totals and bestQuizTimeSeconds', () { + bloc.add( + CheckAchievements( + quizCount: 3, + correctAnswers: 5, + level: 2, + quizTimeSeconds: 50, + ), + ); + bloc.stream.listen( + expectAsync1((state) { + expect(state.totalQuizzes, 3); + expect(state.totalCorrectAnswers, 5); + expect(state.currentLevel, 2); + expect(state.bestQuizTimeSeconds, 50); + }), + ); + }); + + test('fromJson and toJson work correctly', () { + final state = AchievementsState( + unlockedLevels: {AchievementCategory.leveling: AchievementLevel.bronze}, + totalQuizzes: 1, + totalCorrectAnswers: 2, + currentLevel: 3, + bestQuizTimeSeconds: 100, + ); + final json = bloc.toJson(state)!; + final newState = bloc.fromJson(json)!; + expect(newState.unlockedLevels, state.unlockedLevels); + expect(newState.totalQuizzes, state.totalQuizzes); + expect(newState.totalCorrectAnswers, state.totalCorrectAnswers); + expect(newState.currentLevel, state.currentLevel); + expect(newState.bestQuizTimeSeconds, state.bestQuizTimeSeconds); + }); + }); +} diff --git a/test/blocs/browse_bloc_test.dart b/test/blocs/browse_bloc_test.dart new file mode 100644 index 0000000..4a527eb --- /dev/null +++ b/test/blocs/browse_bloc_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quiz_master/features/browse/data/model/quiz.dart'; +import 'package:quiz_master/features/browse/presentation/blocs/browse/browse_bloc.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late BrowseBloc bloc; + + final quiz1 = Quiz( + id: 1, + title: 'Flutter Basics', + category: 1, + icon: Icons.code, + description: 'Step by step guide to Flutter', + ); + + final quiz2 = Quiz( + id: 2, + title: 'Dart Advanced', + category: 1, + icon: Icons.code, + description: 'Deep dive into Dart', + ); + + group('BrowseBloc Tests', () { + setUp(() { + bloc = BrowseBloc(); + + bloc.emit( + bloc.state.copyWith( + allQuizzes: [quiz1, quiz2], + filteredQuizzes: [quiz1, quiz2], + ), + ); + }); + + test('initial state is empty', () { + final newBloc = BrowseBloc(); + expect(newBloc.state.allQuizzes, []); + expect(newBloc.state.filteredQuizzes, []); + expect(newBloc.state.query, ''); + }); + + test('BrowseQueryChanged filters quizzes correctly', () async { + bloc.add(const BrowseQueryChanged('flutter')); + + await expectLater( + bloc.stream, + emits(bloc.state.copyWith(filteredQuizzes: [quiz1], query: 'flutter')), + ); + }); + + test('BrowseReset resets filtered quizzes', () async { + bloc.emit( + bloc.state.copyWith(filteredQuizzes: [quiz1], query: 'flutter'), + ); + + bloc.add(BrowseReset()); + + await expectLater( + bloc.stream, + emits(bloc.state.copyWith(filteredQuizzes: [quiz1, quiz2], query: '')), + ); + }); + }); +} diff --git a/test/blocs/quiz_cubit_test.dart b/test/blocs/quiz_cubit_test.dart new file mode 100644 index 0000000..ac57ea3 --- /dev/null +++ b/test/blocs/quiz_cubit_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:quiz_master/features/quiz/domain/entity/quiz_params.dart'; +import 'package:quiz_master/features/quiz/domain/usecase/get_quiz.dart'; +import 'package:quiz_master/features/quiz/domain/entity/question.dart'; +import 'package:quiz_master/features/quiz/presentation/blocs/quiz/quiz_cubit.dart'; + +class MockGetQuiz extends Mock implements GetQuiz {} + +class FakeQuizParams extends Fake implements QuizParams {} + +void main() { + late QuizCubit cubit; + late MockGetQuiz mockGetQuiz; + + final sampleQuestions = [ + Question( + category: 'General', + type: 'multiple', + difficulty: 'easy', + question: 'Sample question 1?', + correctAnswer: 'Answer 1', + incorrectAnswers: ['Wrong 1', 'Wrong 2', 'Wrong 3'], + ), + ]; + + setUpAll(() { + registerFallbackValue(FakeQuizParams()); + }); + + setUp(() { + mockGetQuiz = MockGetQuiz(); + cubit = QuizCubit(getQuiz: mockGetQuiz); + }); + + group('QuizCubit Tests', () { + test('initial state is QuizInitial', () { + expect(cubit.state, isA()); + }); + + test('loadQuiz emits QuizLoading then QuizLoaded on success', () async { + when(() => mockGetQuiz(any())).thenAnswer((_) async => sampleQuestions); + + final future = expectLater( + cubit.stream, + emitsInOrder([ + isA(), + isA().having( + (s) => s.questions, + 'questions', + sampleQuestions, + ), + ]), + ); + + await cubit.loadQuiz(const QuizParams(amount: 1)); + + await future; + }); + + test('loadQuiz emits QuizLoading then QuizError on failure', () async { + when(() => mockGetQuiz(any())).thenThrow(Exception('Failed')); + + final future = expectLater( + cubit.stream, + emitsInOrder([ + isA(), + isA().having( + (s) => s.message, + 'message', + contains('Failed'), + ), + ]), + ); + + await cubit.loadQuiz(const QuizParams(amount: 1)); + + await future; + + verify(() => mockGetQuiz(any())).called(1); + }); + }); +} diff --git a/test/blocs/quiz_run_cubit_test.dart b/test/blocs/quiz_run_cubit_test.dart new file mode 100644 index 0000000..99ff2d7 --- /dev/null +++ b/test/blocs/quiz_run_cubit_test.dart @@ -0,0 +1,130 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quiz_master/features/quiz/domain/entity/question.dart'; +import 'package:quiz_master/features/quiz/presentation/blocs/quiz_run/quiz_run_cubit.dart'; + +void main() { + group('QuizRunCubit', () { + late List questions; + + setUp(() { + questions = [ + Question( + category: 'General', + type: 'multiple', + difficulty: 'easy', + question: 'Q1', + correctAnswer: 'A1', + incorrectAnswers: ['A2', 'A3', 'A4'], + ), + Question( + category: 'General', + type: 'multiple', + difficulty: 'easy', + question: 'Q2', + correctAnswer: 'B1', + incorrectAnswers: ['B2', 'B3', 'B4'], + ), + ]; + }); + + test('initial state has correct values', () { + final cubit = QuizRunCubit(questions, 20); + expect(cubit.state.questions, questions); + expect(cubit.state.currentIndex, 0); + expect(cubit.state.score, 0); + expect(cubit.state.timeLeft, 20); + cubit.close(); + }); + + test('timer counts down and calls nextQuestion automatically', () { + fakeAsync((async) { + final cubit = QuizRunCubit(questions, 3); + + expect(cubit.state.timeLeft, 3); + + async.elapse(const Duration(seconds: 1)); + expect(cubit.state.timeLeft, 2); + + async.elapse(const Duration(seconds: 2)); + expect(cubit.state.currentIndex, 1); + expect(cubit.state.timeLeft, 3); + + cubit.close(); + }); + }); + + test('answer correct increments score and locks question', () { + fakeAsync((async) { + final cubit = QuizRunCubit(questions, 20); + + cubit.answer('A1'); + expect(cubit.state.score, 1); + expect(cubit.state.isLocked, true); + expect(cubit.state.selectedAnswer, 'A1'); + + async.elapse(const Duration(seconds: 1)); + expect(cubit.state.currentIndex, 1); + expect(cubit.state.isLocked, false); + + cubit.close(); + }); + }); + + test('answer incorrect does not increment score', () { + fakeAsync((async) { + final cubit = QuizRunCubit(questions, 20); + + cubit.answer('Wrong'); + expect(cubit.state.score, 0); + expect(cubit.state.isLocked, true); + + async.elapse(const Duration(seconds: 1)); + expect(cubit.state.currentIndex, 1); + + cubit.close(); + }); + }); + + test('useFiftyFifty disables two incorrect answers', () { + final cubit = QuizRunCubit(questions, 20); + + expect(cubit.state.lifelineFiftyFiftyAvailable, true); + cubit.useFiftyFifty(); + expect(cubit.state.lifelineFiftyFiftyAvailable, false); + expect(cubit.state.disabledAnswers.length, 2); + cubit.close(); + }); + + test('useSkip skips to next question', () { + fakeAsync((async) { + final cubit = QuizRunCubit(questions, 20); + + expect(cubit.state.currentIndex, 0); + expect(cubit.state.lifelineSkipAvailable, true); + + cubit.useSkip(); + expect(cubit.state.lifelineSkipAvailable, false); + + async.elapse(const Duration(seconds: 0)); + expect(cubit.state.currentIndex, 1); + + cubit.close(); + }); + }); + + test('finishing last question shows results', () { + fakeAsync((async) { + final cubit = QuizRunCubit(questions, 1); + + cubit.answer('A1'); + async.elapse(const Duration(seconds: 1)); + cubit.answer('B1'); + async.elapse(const Duration(seconds: 1)); + + expect(cubit.state.showResults, true); + cubit.close(); + }); + }); + }); +} diff --git a/test/blocs/theme_cubit_test.dart b/test/blocs/theme_cubit_test.dart new file mode 100644 index 0000000..a95aae5 --- /dev/null +++ b/test/blocs/theme_cubit_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:quiz_master/core/presentation/blocs/theme_cubit.dart'; + +class MockStorage extends Mock implements Storage {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Storage storage; + + group('ThemeCubit tests', () { + late ThemeCubit cubit; + + setUp(() { + storage = MockStorage(); + when(() => storage.write(any(), any())).thenAnswer((_) async {}); + HydratedBloc.storage = storage; + + cubit = ThemeCubit(); + }); + + test('initial state should be ThemeMode.dark', () { + expect(cubit.state.themeMode, ThemeMode.dark); + }); + + test('setThemeMode should emit correct ThemeState', () { + cubit.setThemeMode(ThemeMode.light); + expect(cubit.state.themeMode, ThemeMode.light); + + cubit.setThemeMode(ThemeMode.dark); + expect(cubit.state.themeMode, ThemeMode.dark); + }); + + test('fromJson should deserialize correctly', () { + expect( + cubit.fromJson({'themeMode': 'light'})?.themeMode, + ThemeMode.light, + ); + expect(cubit.fromJson({'themeMode': 'dark'})?.themeMode, ThemeMode.dark); + expect( + cubit.fromJson({'themeMode': 'unknown'})?.themeMode, + ThemeMode.dark, + ); + expect(cubit.fromJson({})?.themeMode, ThemeMode.dark); + }); + + test('toJson should serialize correctly', () { + expect(cubit.toJson(const ThemeState(themeMode: ThemeMode.light)), { + 'themeMode': 'light', + }); + expect(cubit.toJson(const ThemeState(themeMode: ThemeMode.dark)), { + 'themeMode': 'dark', + }); + expect(cubit.toJson(const ThemeState(themeMode: ThemeMode.system)), { + 'themeMode': 'dark', + }); + }); + }); +} diff --git a/test/blocs/user_level_bloc_test.dart b/test/blocs/user_level_bloc_test.dart new file mode 100644 index 0000000..6492fda --- /dev/null +++ b/test/blocs/user_level_bloc_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:quiz_master/features/leveling/presentation/blocs/user_level/user_level_bloc.dart'; + +class MockStorage extends Mock implements Storage {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late UserLevelBloc bloc; + late Storage storage; + + setUp(() { + storage = MockStorage(); + when(() => storage.write(any(), any())).thenAnswer((_) async {}); + when(() => storage.read(any())).thenReturn(null); + HydratedBloc.storage = storage; + + bloc = UserLevelBloc(baseXp: 100, exponent: 1.0); + }); + + group('UserLevelBloc Tests', () { + test('initial state is level 1, xp 0', () { + expect(bloc.state.level, 1); + expect(bloc.state.currentXp, 0); + }); + + test('AddXp increases XP correctly without leveling up', () async { + bloc.add(const AddXp(50)); + + await expectLater( + bloc.stream, + emits(bloc.state.copyWith(level: 1, currentXp: 50)), + ); + }); + + test('AddXp levels up when XP exceeds threshold', () async { + bloc.add(const AddXp(150)); + + await expectLater( + bloc.stream, + emits(bloc.state.copyWith(level: 2, currentXp: 50)), + ); + }); + + test('AddXp handles multiple level-ups', () async { + bloc.add(const AddXp(250)); + await expectLater( + bloc.stream, + emits(bloc.state.copyWith(level: 2, currentXp: 150)), + ); + }); + + test('ResetLevel resets level and XP', () async { + bloc.add(const AddXp(120)); + bloc.add(ResetLevel()); + + await expectLater( + bloc.stream, + emitsInOrder([ + bloc.state.copyWith(level: 2, currentXp: 20), + const UserLevelState(level: 1, currentXp: 0), + ]), + ); + }); + + test('fromJson and toJson work correctly', () { + final state = UserLevelState(level: 5, currentXp: 42); + final json = bloc.toJson(state); + expect(json, {'level': 5, 'currentXp': 42}); + + final restored = bloc.fromJson(json!); + expect(restored!.level, 5); + expect(restored.currentXp, 42); + }); + }); +} diff --git a/test/data/datasources/quiz_remote_data_source_test.dart b/test/data/datasources/quiz_remote_data_source_test.dart new file mode 100644 index 0000000..384aef9 --- /dev/null +++ b/test/data/datasources/quiz_remote_data_source_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import 'package:quiz_master/features/quiz/data/datasource/quiz_remote_data_source.dart'; +import 'package:quiz_master/features/quiz/data/model/question_model.dart'; +import 'package:quiz_master/features/quiz/domain/entity/quiz_params.dart'; + +class FakeHttpClient implements http.Client { + final int statusCode; + final String body; + + FakeHttpClient({required this.statusCode, required this.body}); + + @override + Future get(Uri url, {Map? headers}) async { + return http.Response(body, statusCode); + } + + @override + void close() {} + @override + Future delete( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => throw UnimplementedError(); + @override + Future head(Uri url, {Map? headers}) => + throw UnimplementedError(); + @override + Future patch( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => throw UnimplementedError(); + @override + Future post( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => throw UnimplementedError(); + @override + Future put( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => throw UnimplementedError(); + @override + Future read(Uri url, {Map? headers}) => + throw UnimplementedError(); + @override + Future readBytes(Uri url, {Map? headers}) => + throw UnimplementedError(); + + @override + Future send(http.BaseRequest request) { + throw UnimplementedError(); + } +} + +void main() { + late QuizRemoteDataSourceImpl dataSource; + + group('QuizRemoteDataSourceImpl (unit tests)', () { + const tParams = QuizParams(amount: 2, category: 9, difficulty: 'easy'); + + final validResponseJson = json.encode({ + "response_code": 0, + "results": [ + { + "category": "General Knowledge", + "type": "multiple", + "difficulty": "easy", + "question": "What is the capital of France?", + "correct_answer": "Paris", + "incorrect_answers": ["London", "Berlin", "Madrid"], + }, + { + "category": "Science", + "type": "boolean", + "difficulty": "easy", + "question": "The chemical symbol for water is H2O.", + "correct_answer": "True", + "incorrect_answers": ["False"], + }, + ], + }); + + test('returns a list of QuestionModel when response is valid', () async { + final fakeClient = FakeHttpClient( + statusCode: 200, + body: validResponseJson, + ); + dataSource = QuizRemoteDataSourceImpl(client: fakeClient); + + final result = await dataSource.fetchQuiz(tParams); + + expect(result, isA>()); + expect(result.length, 2); + expect(result.first.correctAnswer, 'Paris'); + expect(result.first.allAnswers.contains('Paris'), true); + expect(result.first.allAnswers.contains('London'), true); + }); + + test('throws Exception when status code is not 200', () async { + final fakeClient = FakeHttpClient(statusCode: 500, body: 'Server Error'); + dataSource = QuizRemoteDataSourceImpl(client: fakeClient); + + expect(() => dataSource.fetchQuiz(tParams), throwsA(isA())); + }); + + test('throws Exception when response_code != 0', () async { + final invalidResponse = json.encode({"response_code": 1, "results": []}); + final fakeClient = FakeHttpClient(statusCode: 200, body: invalidResponse); + dataSource = QuizRemoteDataSourceImpl(client: fakeClient); + + expect(() => dataSource.fetchQuiz(tParams), throwsA(isA())); + }); + + test('builds correct URI with all parameters', () async { + final fakeClient = FakeHttpClient( + statusCode: 200, + body: validResponseJson, + ); + dataSource = QuizRemoteDataSourceImpl(client: fakeClient); + + final uri = Uri.parse('https://opentdb.com/api.php').replace( + queryParameters: { + 'amount': tParams.amount.toString(), + 'category': tParams.category.toString(), + 'difficulty': tParams.difficulty!, + }, + ); + + final result = await dataSource.fetchQuiz(tParams); + expect(result, isNotEmpty); + expect(uri.queryParameters['amount'], '2'); + expect(uri.queryParameters['category'], '9'); + expect(uri.queryParameters['difficulty'], 'easy'); + }); + }); +} diff --git a/test/dummy_test.dart b/test/dummy_test.dart deleted file mode 100644 index cdc3313..0000000 --- a/test/dummy_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('dummy test', () { - expect(true, isTrue); - }); -}