Search code examples
flutterdartunit-testingmockitowidget

Title: TypeError in Flutter Test: Null is not a subtype of Future<String>


I'm writing a Flutter test where I'm trying to mock a method that calculates the BMI. Despite using when(...).thenReturn(...) to return a future value, I'm encountering the following error:

══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════ The following _TypeError was thrown running a test: type 'Null' is not a subtype of type 'Future<String>'

How It Works

  1. User Interaction:

    • The user opens the app and enters their weight and height in the provided text fields.
    • The user presses the "Calculate BMI" button.
  2. Frontend Processing:

    • The app sends a request to the backend API with the weight and height as parameters.
    • The app uses the Provider package to manage state and the ApiService to handle the HTTP request.
  3. Backend Processing:

    • The Spring Boot application receives the request and processes the BMI calculation.
    • The result is sent back to the app as a response.
  4. Result Display:

    • The app displays the BMI result and changes the text color based on the BMI category.

Here is relevant part of my code:

widget_test.dart

// Define a mock class for ApiService using Mockito
class MockApiService extends Mock implements ApiService {}
void main() {
  testWidgets('BMI Calculator Test', (WidgetTester tester) async {
    final MockApiService mockApiService = MockApiService();

    // Mock response for calculateBMI method
    when(mockApiService.calculateBMI(88, 186)).thenReturn(Future.value('22.2'));

    await tester.pumpWidget(
      ChangeNotifierProvider(
        create: (context) => mockApiService,
        child: MaterialApp(
          home: BmiCalculatorApp(),
        ),
      ),
    );

    // Verify if 'Calculate BMI' button is found on the screen
    expect(find.text('Calculate BMI'), findsOneWidget);

    // Find the ElevatedButton widget with 'Calculate BMI' text and tap it
    await tester.tap(find.widgetWithText(ElevatedButton, 'Calculate BMI'));
    await tester.pump();

    // Wait for the async operation to complete
    await tester.pump(Duration(seconds: 1));

    // Verify if 'BMI result:' text is found exactly once on the screen
    expect(find.text('Your BMI is 22.2'), findsOneWidget);
  });
}

api_service.dart

import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';

// Define a service class
class ApiService {
  Future<String> calculateBMI(int weight, int height) async {
    // Implementation that calculates BMI
    return ((weight / (height / 100 * height / 100)).toStringAsFixed(1));
  }
}

// Define a mock class
class MockApiService extends Mock implements ApiService {}

void main() {
  // Create an instance of the mock class
  final mockApiService = MockApiService();

  // Set up the mock to return a specific value
  when(mockApiService.calculateBMI(88, 186)).thenReturn(Future.value('22.2'));

  // Test case
  test('Calculate BMI', () async {
    final result = await mockApiService.calculateBMI(88, 186);
    expect(result, '22.2');
  });
}

main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'api_service.dart'; // Import the ApiService class

void main() {
  runApp(
    // This is the entry point of my Flutter application.
    ChangeNotifierProvider( // This widget is used to provide a ChangeNotifier to its descendants. ChangeNotifierProvider: Listens for changes in the information and updates the app automatically.
      create: (context) => ApiService(baseUrl: 'http://localhost:8080'), // Create an instance of ApiService with a base URL.
      child: MaterialApp( // A widget that configures the MaterialApp.
        home: BmiCalculatorApp(), // Set BmiCalculatorApp as the home screen of the app.
      ),
    ),
  );
}

class BmiCalculatorApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold( // Scaffold widget provides a standard app layout structure.
      appBar: AppBar( // AppBar is the top bar of the app that typically contains the title and actions.
        title: Text('BMI Calculator'), // Title of the AppBar.
      ),
      body: BmiCalculator(), // The main content of the app is BmiCalculator widget.
    );
  }
}

class BmiCalculator extends StatefulWidget {
  @override
  _BmiCalculatorState createState() => _BmiCalculatorState();
}

class _BmiCalculatorState extends State<BmiCalculator> {
  final TextEditingController _weightController = TextEditingController(); // Controller to manage weight input.
  final TextEditingController _heightController = TextEditingController(); // Controller to manage height input.
  String _message = "Please enter your height and weight"; // Initial message displayed to the user.
  Color _messageColor = Colors.black; // Color of the message text.

  @override
  void initState() {
    super.initState(); // super.initState() This method call ensures that the initialization process of the parent class (State) is executed. The parent class State may have some essential setup that needs to be done for your widget to function correctly.
    // No need to initialize ApiService here anymore
  }

  void _calculateBMI() async {
    double weight = double.parse(_weightController.text); // Get weight value from TextField.
    double height = double.parse(_heightController.text); // Get height value from TextField.

    try {
      // Use Provider.of to get ApiService instance.
      final ApiService apiService = Provider.of<ApiService>(context, listen: false); // Get ApiService instance.
      String result = await apiService.calculateBMI(weight, height); // Call calculateBMI method from ApiService.

      // Extract the BMI value from the result string.

      double bmi = double.parse(result.split(' ').last);

      // Determine the color based on the BMI value.
      if (bmi < 18.5) {
        _messageColor = Colors.orange; // Underweight
      } else if (bmi < 25) {
        _messageColor = Colors.green; // Healthy Weight
      } else if (bmi < 30) {
        _messageColor = Colors.red; // Overweight
      } else {
        _messageColor = Colors.grey; // Obese
      }

      setState(() {
        _message = result; // Update _message with the result.
      });
    } catch (e) {
      setState(() {
        _message = 'Error: $e'; // If an error occurs, update _message with error message.
        _messageColor = Colors.black; // Set color to black for error message.
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0), // Padding around the entire Column widget.
      child: Column( // Column widget to arrange children widgets vertically.
        crossAxisAlignment: CrossAxisAlignment.stretch, // Stretch widgets horizontally.
        children: <Widget>[
          TextField( // TextField widget for weight input.
            controller: _weightController, // Controller to manage text input.
            decoration: InputDecoration( // Decoration for TextField.
              labelText: 'Weight (kg)', // Label text for TextField.
            ),
            keyboardType: TextInputType.number, // Allow numeric input.
          ),
          TextField( // TextField widget for height input.
            controller: _heightController, // Controller to manage text input.
            decoration: InputDecoration( // Decoration for TextField.
              labelText: 'Height (cm)', // Label text for TextField.
            ),
            keyboardType: TextInputType.number, // Allow numeric input.
          ),
          SizedBox(height: 20), // Empty space with a height of 20 pixels.
          ElevatedButton( // ElevatedButton widget for BMI calculation.
            onPressed: _calculateBMI, // Function to execute when button is pressed.
            child: Text('Calculate BMI'), // Text displayed on the button.
          ),
          SizedBox(height: 20), // Empty space with a height of 20 pixels.
          Text( // Text widget to display BMI calculation result or error message.
            _message, // Text to display.
            textAlign: TextAlign.center, // Center-align the text.
            style: TextStyle(fontSize: 24, color: _messageColor), // Font size and color of the text.
          ),
        ],
      ),
    );
  }
}

pubspec.yaml

remove all comments: 
name: bmi_calculator
description: "A new Flutter project."


version: 1.0.0+1

environment:
  sdk: '>=3.4.3 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  http: ^0.13.3
  provider: ^5.0.0

  cupertino_icons: ^1.0.6

dev_dependencies:
  flutter_test:
    sdk: flutter

  mockito: ^5.0.7
  
  flutter_lints: ^3.0.0

  uses-material-design: true

What I Have Tried:

  1. Using thenAnswer:

    when(mockApiService.calculateBMI(88, 186)).thenAnswer((_) async => '22.2');
    
  2. Using thenReturn with Generic:

    when(mockApiService.calculateBMI(any, any)).thenReturn(Future<String>.value('22.2'));
    
  3. Verifying Arguments:

    when(mockApiService.calculateBMI(88, 186)).thenAnswer((invocation) async {
      final int weight = invocation.positionalArguments[0];
      final int height = invocation.positionalArguments[1];
      return '22.2';
    });
    

Issue:

Despite these attempts, I am still encountering the type 'Null' is not a subtype of type 'Future<String>' error.My main.dart 

Question:

How can I resolve this TypeError and correctly mock the calculateBMI method to return the expected future value?

My Flutter program runs perfectly, but I can't seem to pass the test in widget_test.dart


Solution

  • This looks like a problem with Mockito and Dart sound null safety.

    As the NULL_SAFETY_README says, the following code is illegal under sound null safety for reasons of argument matching and return types. For details see the link.

    class HttpServer {
      Uri start(int port)  {
        // ...
      }
    }
    
    class MockHttpServer extends Mock implements HttpServer {}
    
    var server = MockHttpServer();
    var uri = Uri.parse('http://localhost:8080');
    when(server.start(any)).thenReturn(uri);
    

    The same applies to you ApiServer mocking.

    The readme offers two solutions: Code generation or manual mock implementation using reflection. In the latter case, the mock would look like

    class MockHttpServer extends Mock implements HttpServer {
      @override
      void start(int? port) =>
          super.noSuchMethod(Invocation.method(#start, [port]));
    }
    

    From there, you can use when() or capture() as before.

    For more complex mocks the code generation approach is preferable.