Cómo crear un cliente API REST y sus tests de integración en Flutter

La mayoría de las aplicaciones móviles se basan en una comunicación con un API Rest.

Tener una buena integración con un servicio externo es fundamental en el desarrollo móvil. Para tener una integración óptima es necesario asegurarse del correcto funcionamiento mediante tests de integración.

Hace un año esribí sobre este mismo tema pero aplicado a Kotlin multiplatform.

Desde hace tiempo estoy trabajando con Flutter y ya iba tocando escribir sobre este tema aplicado a Flutter.

En este artículo vamos a ver cómo poder crear un cliente API Rest en Flutter y sus test de integración necesarios para verificar que nuestra integración funciona correctamente.

API Rest

El cliente que vamos a crear se comunica con el siguiente servicio web: http://jsonplaceholder.typicode.com/

Este API REST gestiona tareas, vas a poder obtener todas las tareas existentes, obtener una tarea utilizando su identificador, añadir una nueva tarea, actualizar una tarea o eliminar una tarea existente.

Creando el proyecto

Lo primero es crear el proyecto, podrás utilizar Android Studio o Visual Studio Code.

Explicar con detalle como instalar Flutter y configurar Android Studio o Visual Studio Code para trabajar con Flutter se escapa del alcance de este artículo pero, aquí dejo la documentación oficial de Flutter al respecto: Instalación Flutter.

Configurando la integración continua

El mejor momento para configurar la integración contínua en un proyecto es al principio, así que sería el siguiente paso.

Yo he utilizado Codemagic, es la heramienta de integración continua que más me ha gustado para Flutter por su simplifidad con apenas configuración.

Esta enfocada exclusivamente para Flutter y por este motivo resulta muy fácil.

Es posible realizar la configuracion mediante su web o mediante fichero YAML.

Os dejo documentación de codemagic para la configuración: Configuración Codemagic.

Creando el cliente

Paquetes a utilizar

En flutter las librerías se llaman paquetes y vamos a necesitar:

  • http, es un paquete desarrollado por el equipo de flutter para realizar peticiones a un servicio remoto. Sería como Retrofit en Android o en iOS como Alamofire y AFNetworking.

TodoApiClient

Nuestro cliente se va a componer de un cliente http, al que realizaremos la peticiones y recibirá la dirección base en el constructor, veremos por qué más adelante.

Necesitamos manejar los errores que nos devuelva la api. En esta ocasión generaremos una excepción para cada error.

En un futuro artículo veremos como hacerlo en un estilo más funcional utilizando un tipo Either como valor de retorno al igual que en la version de Kotlin multiplatform que ecribí el año pasado.

class TodoApiClient {
    ...
    
    http.Response returnResponseOrThrowException(http.Response response) {
    if (response.statusCode == 404) {
      throw ItemNotFoundException();
    } else if (response.statusCode > 400) {
      throw UnKnowApiException(response.statusCode);
    } else {
      return response;
    }
  }
  
  ...
}

En esta clase necesitamos un poco de infraestructura. Vamos a tener un método privado por cada verbo donde realizaremos la petición al endpoint correspondiente, asignaremos headers y body si procede.

class TodoApiClient {
  final String _baseAddress;

  TodoApiClient(this._baseAddress);

  ...
 
  Future<http.Response> _get(String endpoint) async {
    try {
      final response = await http.get(
        '$_baseAddress$endpoint',
        headers: {
          HttpHeaders.acceptHeader: 'application/json',
        },
      );

      return returnResponseOrThrowException(response);
    } on IOException catch (e) {
      print(e.toString());
      throw NetworkException();
    }
  }

  Future<http.Response> _post(Task task) async {
    try {
      final response = await http.post(
        '$_baseAddress/todos',
        body: json.encode(task.toJson()) ,
        headers: {
          HttpHeaders.acceptHeader: 'application/json',
          HttpHeaders.contentTypeHeader: 'application/json',
        },
      );

      return returnResponseOrThrowException(response);
    } on IOException {
      throw NetworkException();
    }
  }

  Future<http.Response> _put(Task task) async {
    try {
      final response = await http.put(
        '$_baseAddress/todos/${task.id}',
        body: json.encode(task.toJson()) ,
        headers: {
          HttpHeaders.acceptHeader: 'application/json',
          HttpHeaders.contentTypeHeader: 'application/json',
        },
      );

      return returnResponseOrThrowException(response);
    } on IOException  {
      throw NetworkException();
    }
  }

  Future<http.Response> _delete(String id) async {
    try {
      final response = await http.delete(
        '$_baseAddress/todos/$id',
        headers: {
          HttpHeaders.acceptHeader: 'application/json',
        },
      );

      return returnResponseOrThrowException(response);
    } on IOException  {
      throw NetworkException();
    }
  }
  
  ...

}

Vamos a tener un método por cada una de las acciones que nos permite el servicio remoto:

class TodoApiClient {

    ...
    
  Future<List<Task>> getAllTasks() async {
    final response = await _get('/todos');

    final decodedTasks = json.decode(response.body) as List;

    return decodedTasks.map((jsonTask) => Task.fromJson(jsonTask)).toList();
  }

  Future<Task> getTasksById(String id) async {
    final response = await _get('/todos/$id');

    return Task.fromJson(json.decode(response.body));
  }

  Future<Task> addTask(Task task) async {
    final response = await _post(task);

    return Task.fromJson(json.decode(response.body));
  }

  Future<Task> updateTask(Task task) async {
    final response = await _put(task);

    return Task.fromJson(json.decode(response.body));
  }

  Future<void> deleteTaskById(String id) async {
    await _delete(id);
  }
  
    ...
}

Respecto a la serialización, en flutter esta desactivada y existen dos opciones:

  • Serialización manual, adecuada para proyectos pequeños como el que he creado para este ejemplo.
  • Serialización mediante generación de código, para proyectos medianos y grandes.

Os dejo un enlace a la documentación de Flutter sobre serialización para que podáis ampliar información en este punto: Flutter serialización.

Tests de integración

Para probar la integración de nuestro cliente con el API Rest necesitamos verificar lo siguiente:

  • Se envían correctamente las peticiones a la API: endpoint, verbo, cabeceras, body si corresponde.
  • Se parsean correctamente las respuestas del servidor.
  • Se manejan las respuestas de error del servidor correctamente.

Para poder realizar estas verificaciones tenemos que tener la capacidad de simular respuestas de servidor y poder acceder de algún modo a las peticiones http que se envían.

Paquetes a utilizar

Existen dos estrategias posibles en este punto:

  • Utilizar un mock del cliente http, el paquete http de flutter proporciona una clase mockclient para realizar esto. Este cliente debería ser pasado como argumento a nuestro TodoApiClient. Sin embargo este cliente mock es un poco limitado en escenarios más complejos, donde una llamada a TodoApiClient supone varias llamadas al servidor por ejemplo para renovar el token. Para ejemplos sencillos es perfectamente válida esta opción.
  • Utilizar un servidor mock, esta es la opción que suelo utilizar, se trata de iniciar un servidor HTTP embebido donde podremos configurar nuestras respuestas y solicitar las peticiones que se le envían para validarlas. La url del servidor mock se tiene que pasar como argumento en el constructor de TodoApiClient.

Necesitamos el siguiente paquete:

  • mock-web-server, esta basado en la librería con el mismo nombre creada por Square para Java y muy utilizada en Android.

Creando los tests

Vamos a ver algunos test de los que podemos crear.

Lo primero que podríamos probar es verificar el endpoint /todos:

  • Verificar que la respuesta es parseada correctamente.
  • Verificar que la cabecera accept es enviada correctamente
  • Verificar que en caso de error, este es procesado adecuadamente.

¿Que infraestructura necesitamos?, necesitamos tener una forma de encolar respuestas en el servidor mock y verificar las solucitudes que se le envían. Pasaremos la url de este servidor a nuestro TodoApiClient.

Necesitamos un fichero json que represente la respuesta del servidor:

// get_tasks_response.json
[
  {
    "userId": 1,
    "id": 1,
    "title": "delectus aut autem",
    "completed": false
  },
  {
    "userId": 1,
    "id": 2,
    "title": "quis ut nam facilis et officia qui",
    "completed": false
  },
  {
    "userId": 1,
    "id": 3,
    "title": "fugiat veniam minus",
    "completed": false
  }
]

Ahora necesitamos poder leer este fichero y encolarlo como la siguiente respuesta del servidor mock y perdir al servidor mock la request realizada para realizar validaciones sobre ella.

Podemos crearnos una clase base para nuestros tests que se encargue de ello o crear una clase específica. A mí me gusta más favorecer la composición sobre la herencia también en los test.

class MockApi {
  MockWebServer _server;

  Future<void> start() async {
    _server = MockWebServer();
    await _server.start();
  }

  void shutdown() {
    _server.shutdown();
  }

  String get baseAddress => _server.url.substring(0, _server.url.length - 1);

  Future<void> enqueueMockResponse(
      {String fileName = '',
      int httpCode = 200,
      Map<String, String> headers}) async {
    final content = await _getContentFromFile(fileName: fileName);

    _server.enqueue(body: content, httpCode: httpCode, headers: headers);
  }

  void expectRequestSentTo(String endpoint) {
    final StoredRequest storedRequest = _server.takeRequest();

    expect(storedRequest.uri.path, endpoint);
  }

  void expectRequestContainsHeader([String key, String expectedValue, int requestIndex = 0]) {
    final StoredRequest storedRequest = _getRecordedRequestAtIndex(requestIndex);
    final value = storedRequest.headers[key];

    expect(value, contains(expectedValue));
  }

  Future<void> expectRequestContainsBody(String fileName) async {
    final expectedBody = await _getContentFromFile(fileName: fileName);
    final StoredRequest storedRequest = _getRecordedRequestAtIndex(0);

    expect(storedRequest.body, expectedBody);
  }

  ...
}

Esta clase es un wrapper sobre el servidor mock.

Esta clase añade insfraestructura específica a nuestras necesidades:

  • encolar una respuesta leyendo el contenido de un fichero.
  • realizar validaciones sobre endpoint, cabeceras y body enviados en la petición realizada al servidor mock.

Y por último escribimos nuestros tests:

TodoApiClient _todoApiClient;
MockApi mockApi = MockApi();
String anyTaskId = '1';
Task anyTask =
    Task(id: 1, userId: 1, title: 'Finish this kata', completed: false);

void main() {
  setUp(() async {
    await mockApi.start();

    _todoApiClient = TodoApiClient(mockApi.baseAddress);
  });

  tearDown(() {
    mockApi.shutdown();
  });

  group('TodoApiClient', () {

    group('GetAllTasks should', () {

      test('sends get request to the correct endpoint', () async {
        await mockApi.enqueueMockResponse(fileName: getTasksResponse);

        await _todoApiClient.getAllTasks();

        mockApi.expectRequestSentTo('/todos');
      });

      test('sends accept header', () async {
        await mockApi.enqueueMockResponse(fileName: getTasksResponse);

        await _todoApiClient.getAllTasks();

        mockApi.expectRequestContainsHeader('accept', 'application/json');
      });

      test('parse current news properly getting all current news', () async {
        await mockApi.enqueueMockResponse(fileName: getTasksResponse);

        final tasks = await _todoApiClient.getAllTasks();

        expectTasksContainsExpectedValues(tasks[0]);
      });

      test(
          'throws UnknownErrorException if there is not handled error getting news',
          () async {
        await mockApi.enqueueMockResponse(httpCode: 454);

        expect(() => _todoApiClient.getAllTasks(),
            throwsA(isInstanceOf<UnKnowApiException>()));
      });
    });
    
    ...
    
  });
}

void expectTasksContainsExpectedValues(Task task) {
  expect(task, isNotNull);
  expect(task.userId, 1);
  expect(task.id, 1);
  expect(task.title, 'delectus aut autem');
  expect(task.completed, false);
}

Como se puede apreciar, escribir test en flutter es muy similar a Javascript con Jest por ejemplo.

Kata y código fuente

El código completo lo puedes encontrar aquí.

La rama master contiene todo el kata resuelto por mí.

Pero la mejor forma de aprender es prácticando así que te recomiendo que utilices la rama kata y realizes el ejercicio tu mismo.

Curso y artículos relacionados

Conclusiones

En este artículo hemos visto cómo se puede crear un cliente de API Rest en flutter. También cómo crear tests que nos sirvan para validar la integración con el servicio remoto.