Membuat Aplikasi Movie dengan Flutter - Part 4: Network Use Case & Contract

Kali ini kita akan membuat network use case dan contract pada proyek kita. Untuk use case diagram sudah ada pada artikel bagian 2.

· 10 menit untuk membaca
Membuat Aplikasi Movie dengan Flutter - Part 4: Network Use Case & Contract
Photo by Artur Shamsutdinov / Unsplash

Halo semuanya, pada bagian ini, saya akan membuat use case dan contract pada lapisan domain di aplikasi kita. Untuk diagram use case dapat di lihat pada artikel bagian 2.

Membuat Aplikasi Movie dengan Flutter - Part 2 Analisis Domain
Kita akan melakukan analisis domain terhadap aplikasi yang akan dibuat, yaitu aplikasi Movie

Baiklah langsung saja kita mulai!


Movie Contract

Contract merupakan interface yang akan kita gunakan untuk menghubungkan layer domain dengan layer data.

lib/features/movie/domain/repositories/movie_contract.dart

import 'package:dartz/dartz.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie_detail.dart';
import 'package:movie_flutter/features/movie/domain/entities/search_query.dart';

abstract class MovieContract {
  Future<Either<Failure, List<Movie>>> getTrendingMovies();
  Future<Either<Failure, List<Movie>>> getMoviesByGenre(Genre genre);
  Future<Either<Failure, List<Movie>>> searchMovies(SearchQuery searchQuery);
  Future<Either<Failure, MovieDetail>> getMovieDetail(Movie movie);
}

lib/core/domain/failures.dart

import 'package:freezed_annotation/freezed_annotation.dart';
part 'failure.freezed.dart';

@freezed
class Failure with _$Failure {
  const factory Failure.localFailure({
    required String message,
  }) = MovieLocalFailure;
  const factory Failure.serverFailure({
    required String message,
  }) = MovieServerFailure;
}

lib/features/movie/domain/entities/search_query.dart

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
part 'search_query.freezed.dart';

@freezed
class SearchQuery with _$SearchQuery {
  const factory SearchQuery({
    required Title title,
  }) = _SearchQuery;
}

test/features/movie/domain/entities/search_query_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:movie_flutter/features/movie/domain/entities/search_query.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';

void main() {
  group('SearchQuery entity', () {
    test(
      'should be created',
      () {
        final title = Title('Hello World');

        final query = SearchQuery(title: title);

        expect(query.title, title);
        expect(query.title.getOrCrash(), title.getOrCrash());
      },
    );
  });
}

Karena use case memiliki dependency terhadap MovieContract maka kita akan membuat mock nya dengan menggunakan mocktail

test/features/movie/domain/repositories/movie_contract_mock.dart

import 'package:mocktail/mocktail.dart';
import 'package:movie_flutter/features/movie/domain/repositories/movie_contract.dart';

class MovieContractMock extends Mock implements MovieContract {}

Pertama kita akan menghandle untuk use case mendapatkan trending movie.

test/features/movie/domain/usecases/get_trending_movies.test

import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
import 'package:movie_flutter/features/movie/domain/usecases/get_trending_movies.dart';

import '../repositories/movie_contract_mock.dart';

void main() {
  late GetTrendingMovies usecase;
  late MovieContractMock contract;
  late List<Movie> movies;
  setUp(
    () {
      contract = MovieContractMock();
      usecase = GetTrendingMovies(contract);
      movies = <Movie>[
        Movie(
          id: UniqueId(1),
          title: Title('Hello'),
          poster: Poster(null),
          releaseDate: DateTime.now().subtract(
            const Duration(days: 360),
          ),
          rating: Rating(8),
          trailer: Trailer(null),
        ),
        Movie(
          id: UniqueId(2),
          title: Title('World'),
          poster: Poster(null),
          releaseDate: DateTime.now().subtract(
            const Duration(days: 360),
          ),
          rating: Rating(7),
          trailer: Trailer(null),
        ),
      ];
    },
  );

  group(
    'GetTrendingMovie use case',
    () {
      test(
        'should return list of movies if success',
        () async {
          // arrange
          when(() => contract.getTrendingMovies())
              .thenAnswer((_) async => right<Failure, List<Movie>>(movies));
          // act
          final response = await usecase();
          final data = response.toOption().toNullable();
          // assert
          verify(
            () => contract.getTrendingMovies(),
          ).called(1);
          assert(data != null, 'Response cannot be null here.');
          expect(response.isRight(), true);
          expect(data!.length, 2);
          expect(data.first, movies.first);
        },
      );

      test(
        'should return failure if failed',
        () async {
          // arrange
          when(() => contract.getTrendingMovies()).thenAnswer(
            (_) async => left<Failure, List<Movie>>(
              const Failure.serverFailure(message: 'Not Found'),
            ),
          );
          // act
          final response = await usecase();
          final data = response.toOption().toNullable();
          // assert
          verify(() => contract.getTrendingMovies()).called(1);
          expect(response.isLeft(), true);
          expect(data, null);
        },
      );
    },
  );
}

lib/features/movie/domain/usecase/get_trending_movies.dart

import 'package:dartz/dartz.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/repositories/movie_contract.dart';

class GetTrendingMovies {
  const GetTrendingMovies(this._contract);
  final MovieContract _contract;

  Future<Either<Failure, List<Movie>>> call() {
    return _contract.getTrendingMovies();
  }
}

Karena kita akan membuat usecase lain dengan struktur yang serupa, kita bisa mengabstraksi setiap use case untuk extends dari abstract class.

lib/core/domain/use_case.dart

import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';

abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

class NoParam extends Equatable {
  @override
  List<Object?> get props => [];
}

Jangan lupa tambahkan equatable: ^2.0.3 di pubspec.yaml ya.

Lalu Usecase pertama kita refactor menjadi

import 'package:dartz/dartz.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/use_case.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/repositories/movie_contract.dart';

class GetTrendingMovies extends UseCase<List<Movie>, NoParam> {
  GetTrendingMovies(this._contract);
  final MovieContract _contract;

  @override
  Future<Either<Failure, List<Movie>>> call(NoParam params) {
    return _contract.getTrendingMovies();
  }
}

dan file test nya menjadi

import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/use_case.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
import 'package:movie_flutter/features/movie/domain/usecases/get_trending_movies.dart';

import '../repositories/movie_contract_mock.dart';

void main() {
  late GetTrendingMovies usecase;
  late MovieContractMock contract;
  late List<Movie> movies;
  setUp(
    () {
      contract = MovieContractMock();
      usecase = GetTrendingMovies(contract);
      movies = <Movie>[
        Movie(
          id: UniqueId(1),
          title: Title('Hello'),
          poster: Poster(null),
          releaseDate: DateTime.now().subtract(
            const Duration(days: 360),
          ),
          rating: Rating(8),
          trailer: Trailer(null),
        ),
        Movie(
          id: UniqueId(2),
          title: Title('World'),
          poster: Poster(null),
          releaseDate: DateTime.now().subtract(
            const Duration(days: 360),
          ),
          rating: Rating(7),
          trailer: Trailer(null),
        ),
      ];
    },
  );

  group(
    'GetTrendingMovie use case',
    () {
      test(
        'should return list of movies if success',
        () async {
          // arrange
          when(() => contract.getTrendingMovies())
              .thenAnswer((_) async => right<Failure, List<Movie>>(movies));
          // act
          final response = await usecase(NoParam()); // <- changed
          final data = response.toOption().toNullable();
          // assert
          verify(
            () => contract.getTrendingMovies(),
          ).called(1);
          assert(data != null, 'Response cannot be null here.');
          expect(response.isRight(), true);
          expect(data!.length, 2);
          expect(data.first, movies.first);
        },
      );

      test(
        'should return failure if failed',
        () async {
          // arrange
          when(() => contract.getTrendingMovies()).thenAnswer(
            (_) async => left<Failure, List<Movie>>(
              const Failure.serverFailure(message: 'Not Found'),
            ),
          );
          // act
          final response = await usecase(NoParam()); // <- changed
          final data = response.toOption().toNullable();
          // assert
          verify(() => contract.getTrendingMovies()).called(1);
          expect(response.isLeft(), true);
          expect(data, null);
        },
      );
    },
  );
}

Melihat List Movie berdasarkan Genre

Untuk use case ini, kita membutuhkan parameter dari pengguna berupa Genre.

test/features/movie/domain/usecases/get_movies_by_genre_test.dart

import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
import 'package:movie_flutter/features/movie/domain/usecases/get_movies_by_genre.dart';

import '../repositories/movie_contract_mock.dart';

void main() {
  late GetMoviesByGenre systemUnderTest;
  late MovieContractMock contract;
  late List<Movie> movies;
  late Genre genre;

  setUp(() {
    contract = MovieContractMock();
    systemUnderTest = GetMoviesByGenre(contract);
    movies = <Movie>[
      Movie(
        id: UniqueId(1),
        title: Title('Hello'),
        poster: Poster(null),
        releaseDate: DateTime.now().subtract(
          const Duration(days: 360),
        ),
        rating: Rating(8),
        trailer: Trailer(null),
      ),
      Movie(
        id: UniqueId(2),
        title: Title('World'),
        poster: Poster(null),
        releaseDate: DateTime.now().subtract(
          const Duration(days: 360),
        ),
        rating: Rating(7),
        trailer: Trailer(null),
      ),
    ];
    genre = Genre(id: UniqueId(1), name: StringSingleLine('Action'));
  });

  group('Get Movie by Genre Use Case', () {
    test(
      'should return list of movie when success',
      () async {
        // arrange
        when(
          () => contract.getMoviesByGenre(genre),
        ).thenAnswer(
          (_) async => right<Failure, List<Movie>>(movies),
        );
        // act
        final result = await systemUnderTest(
          GetMoviesByGenreParams(genre),
        );
        final data = result.toOption().toNullable();
        // assert
        verify(() => contract.getMoviesByGenre(genre)).called(1);
        expect(data != null, true);
        expect(result.isRight(), true);
        expect(data!.length, 2);
        expect(data.first, movies.first);
      },
    );

    test(
      'should return failure when failed',
      () async {
        // arrange
        when(
          () => contract.getMoviesByGenre(genre),
        ).thenAnswer(
          (_) async => left<Failure, List<Movie>>(
            const Failure.serverFailure(message: 'Not Found'),
          ),
        );
        // act
        final result = await systemUnderTest(
          GetMoviesByGenreParams(genre),
        );
        final data = result.toOption().toNullable();
        // assert
        verify(() => contract.getMoviesByGenre(genre)).called(1);
        expect(data, null);
        expect(result.isLeft(), true);
      },
    );
  });
}

dan implementasi nya di

lib/features/movie/domain/usecases/get_movies_by_genre.dart

import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/use_case.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/repositories/movie_contract.dart';

class GetMoviesByGenre extends UseCase<List<Movie>, GetMoviesByGenreParams> {
  GetMoviesByGenre(this._contract);
  final MovieContract _contract;
  @override
  Future<Either<Failure, List<Movie>>> call(
    GetMoviesByGenreParams params,
  ) async {
    return _contract.getMoviesByGenre(params.genre);
  }
}

class GetMoviesByGenreParams extends Equatable {
  const GetMoviesByGenreParams(this.genre);
  final Genre genre;
  @override
  List<Object?> get props => [genre];
}

Mencari Movie Berdasarkan Judul

Disini kita akan membuat usecase untuk pencarian movies.

test/features/movie/domain/usecases/search_movies_test.dart

import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/search_query.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
import 'package:movie_flutter/features/movie/domain/usecases/search_movies.dart';

import '../repositories/movie_contract_mock.dart';

void main() {
  late SearchMovies systemUnderTest;
  late MovieContractMock contract;
  late List<Movie> movies;
  late SearchQuery searchQuery;

  setUp(() {
    contract = MovieContractMock();
    systemUnderTest = SearchMovies(contract);
    movies = <Movie>[
      Movie(
        id: UniqueId(1),
        title: Title('Hello'),
        poster: Poster(null),
        releaseDate: DateTime.now().subtract(
          const Duration(days: 360),
        ),
        rating: Rating(8),
        trailer: Trailer(null),
      ),
      Movie(
        id: UniqueId(2),
        title: Title('World'),
        poster: Poster(null),
        releaseDate: DateTime.now().subtract(
          const Duration(days: 360),
        ),
        rating: Rating(7),
        trailer: Trailer(null),
      ),
    ];
    searchQuery = SearchQuery(title: Title('Hello'));
  });

  group('Search Movie use case', () {
    test(
      'should return list of movie when success',
      () async {
        // arrange
        when(
          () => contract.searchMovies(searchQuery),
        ).thenAnswer(
          (_) async => right<Failure, List<Movie>>(movies),
        );
        // act
        final result = await systemUnderTest(
          SearchMoviesParams(searchQuery),
        );
        final data = result.toOption().toNullable();
        // assert
        verify(() => contract.searchMovies(searchQuery)).called(1);
        expect(data != null, true);
        expect(result.isRight(), true);
        expect(data!.length, 2);
        expect(data.first, movies.first);
      },
    );

    test(
      'should return failure when failed',
      () async {
        // arrange
        when(
          () => contract.searchMovies(searchQuery),
        ).thenAnswer(
          (_) async => left<Failure, List<Movie>>(
            const Failure.serverFailure(message: 'Not Found'),
          ),
        );
        // act
        final result = await systemUnderTest(
          SearchMoviesParams(searchQuery),
        );
        final data = result.toOption().toNullable();
        // assert
        verify(() => contract.searchMovies(searchQuery)).called(1);
        expect(data, null);
        expect(result.isLeft(), true);
      },
    );
  });
}

lib/features/movie/domain/usecases/search_movies.dart

import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/use_case.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/search_query.dart';
import 'package:movie_flutter/features/movie/domain/repositories/movie_contract.dart';

class SearchMovies extends UseCase<List<Movie>, SearchMoviesParams> {
  SearchMovies(this._contract);
  final MovieContract _contract;

  @override
  Future<Either<Failure, List<Movie>>> call(SearchMoviesParams params) async {
    return _contract.searchMovies(params.searchQuery);
  }
}

class SearchMoviesParams extends Equatable {
  const SearchMoviesParams(this.searchQuery);
  final SearchQuery searchQuery;
  @override
  List<Object?> get props => [searchQuery];
}

Melihat Detail Movie

test/features/movie/domain/usecases/get_movie_detail_test.dart

import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie_detail.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
import 'package:movie_flutter/features/movie/domain/usecases/get_movie_detail.dart';

import '../repositories/movie_contract_mock.dart';

void main() {
  late GetMovieDetail systemUnderTest;
  late MovieContractMock contract;
  late Movie movie;
  late MovieDetail movieDetail;

  setUp(() {
    contract = MovieContractMock();
    systemUnderTest = GetMovieDetail(contract);
    movie = Movie(
      id: UniqueId(1),
      title: Title('Hello'),
      poster: Poster(null),
      releaseDate: DateTime.now().subtract(
        const Duration(days: 360),
      ),
      rating: Rating(8),
      trailer: Trailer(null),
    );
    movieDetail = MovieDetail(
      id: UniqueId(1),
      title: Title('Hello'),
      poster: Poster(null),
      releaseDate: DateTime.now().subtract(
        const Duration(days: 360),
      ),
      rating: Rating(8),
      trailer: Trailer(null),
      synopsis: 'Synopsis',
      duration: const Duration(minutes: 145),
      genres: <Genre>[
        Genre(
          id: UniqueId(1),
          name: StringSingleLine('Action'),
        ),
      ],
      relatedMovies: <Movie>[
        Movie(
          id: UniqueId(2),
          title: Title('World'),
          poster: Poster(null),
          releaseDate: DateTime.now().subtract(
            const Duration(days: 360),
          ),
          rating: Rating(7),
          trailer: Trailer(null),
        ),
      ],
    );
  });

  group('Get Movie Detail Use case', () {
    test(
      'should return movie detail if success',
      () async {
        // arrange
        when(() => contract.getMovieDetail(movie)).thenAnswer(
          (_) async => right<Failure, MovieDetail>(movieDetail),
        );
        // act
        final result = await systemUnderTest(
          GetMovieDetailParams(movie),
        );
        final data = result.toOption().toNullable();
        // assert
        verify(
          () => contract.getMovieDetail(movie),
        ).called(1);
        expect(result.isRight(), true);
        expect(data != null, true);
        expect(data, movieDetail);
      },
    );

    test(
      'should return failure if failed',
      () async {
        // arrange
        when(() => contract.getMovieDetail(movie)).thenAnswer(
          (_) async => left<Failure, MovieDetail>(
            const Failure.serverFailure(message: 'Not Found'),
          ),
        );
        // act
        final result = await systemUnderTest(GetMovieDetailParams(movie));
        final data = result.toOption().toNullable();
        // assert
        verify(() => contract.getMovieDetail(movie)).called(1);
        expect(result.isLeft(), true);
        expect(data, null);
      },
    );
  });
}

lib/features/movie/domain/usecases/get_movie_detail.dart

import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/use_case.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie_detail.dart';
import 'package:movie_flutter/features/movie/domain/repositories/movie_contract.dart';

class GetMovieDetail extends UseCase<MovieDetail, GetMovieDetailParams> {
  GetMovieDetail(this._contract);
  final MovieContract _contract;
  @override
  Future<Either<Failure, MovieDetail>> call(GetMovieDetailParams params) async {
    return _contract.getMovieDetail(params.movie);
  }
}

class GetMovieDetailParams extends Equatable {
  const GetMovieDetailParams(this.movie);
  final Movie movie;

  @override
  List<Object?> get props => [movie];
}
All test passed!

Sampai di sini dulu, selanjutnya kita akan mengerjakan use case yang membutuhkan data local.