티스토리 뷰

TDD을 적용하기 위해서 가장 필수적인 부분은 테스트가 용이한 구조로 설계되어야 한다는 것이다. 

앱을 구조적으로 만들려면 아키텍처의 각 구성요소들의 역할이 정확하게 정의되야 하고, 그들간의 의존관계가 명확해야 한다.  

지금 부터 어떻게 하면 앱을 테스트하기 좋은 구조 만들 수 있는지 StatefulWidget으로 만들어진 간단한 화면을 예로 설명해보도록 하겠다. 

 

로그인 화면 하나를 만들어 보자 .

로그인 화면이 있고 이 화면에서는 유저로 부터 Id와 password를 입력받아 서버로 인증요청을 보내고 그 결과를 화면에 표시 한다.
- ID는 이메일 형식만 허용한다
- PW는 최소 5자 이상이다.
- 모든 입력이 정상적으로 완료되면 로그인 버튼이 활성화 된다


아직 까지 우리는 구조적으로 분리하지 않았기 때문에 모든 기능을 StatefulWidget에 추가하였고  아래와 같은 코드로 개발하였다. 

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});

  @override
  State<StatefulWidget> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  String _loginId = '';
  String _loginPw = '';
  bool get _enableLoginButton => _loginId.isNotEmpty && _loginPw.isNotEmpty;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          TextFormField(
              initialValue: _loginId,
              onChanged: (value) {
                if (_isValidUserId(value)) setState(() => _loginId = value);
              }),
          TextFormField(
              initialValue: _loginPw,
              onChanged: (value) {
                if (_isValidUserPw(value)) setState(() => _loginPw = value);
              }),
          TextButton(onPressed: _enableLoginButton ? () => _login() : null, child: const Text('Login'))
        ],
      ),
    );
  }

  void _login()  =>  throw UnimplementedError();
  bool _isValidUserId(String value) => true;
  bool _isValidUserPw(String value) => value.length >= 5;
}

 

위 코드를 테스트하기 위해서 우리는 어떻게 테스트 코드를 작성해야 할까? 

우선 위에서 정의한  인풋값(id , pw)의 유효성을 검사하고 로그인 버튼의 활성화 여부를 결정하는 기능의 테스트를 작성해 보자.

testWidgets("Should enable the login button only...",
        (WidgetTester tester) async {
      const myWidget = MaterialApp(home: LoginScreen());
      await tester.pumpWidget(myWidget);
      var loginButton = tester.widget<TextButton>(find.byType(TextButton));
      expect(loginButton.enabled, false);

      final inputFinder = find.byType(TextFormField);
      await tester.enterText(inputFinder.first, 'user01@example.com');
      await tester.enterText(inputFinder.last, 'pw');

      await tester.pump();
      loginButton = tester.widget<TextButton>(find.byType(TextButton));
      expect(loginButton.enabled, false);

      await tester.enterText(inputFinder.last, 'pw0001');
      await tester.pump();
      loginButton = tester.widget<TextButton>(find.byType(TextButton));
      expect(loginButton.enabled, true);
    });

먼가 그럴듯한 테스트 코드가 작성된 것처럼 보인다. 

하지만 로그인 버튼의 활성화상태 여부와 loginId와 loginPw를 테스트하기 위해 그것와 연결된 UI를 모두 만들어야 하고 

테스트 코드에서는 화면의 상태를 확인하기 위해  위젯타입으로 위젯을 찾은 후 그 위젯을 통해 직접 상태를 확인해야 한다. 

위에서 보여주는 것처럼 간단한 예제일 때는 상관이 없을 수 있지만 인풋으로 사용하는 위젯은 TextFormField일수도 있지만 다른 커스텀 위젯으로 변경될 수도 있고, 활성화해야 하는 버튼이 하나가 아닌 2개 일수도 있다.  만약 이미 만들어진 화면을 테스트하는 것이 아니라 TDD로 테스트 코드를 먼저 작성해야 한다면 어떤 위젯을 사용할지 test code에서 미리 결정해야 하기 때문에  test code를 작성하기 더욱 어려울 것이다. 

 

테스트하기 쉬운 구조로 리팩토링 

이제 화면의 상태를 따로 관리하도록 View와 ViewModel로 분리해보자.

    - View에서는 오직 화면을 구성하고 정보를 표시하며, 필요한 상태 정보는 ViewModel로부터 가져온다.

    - ViewModel에서는 View의 상태를 저장하고 로직을 처리한 후 상태를 변경한다. 

 

LoginViewModel과 테스트 코드 

//  LoginViewModel
class LoginViewModel with ChangeNotifier {
  String _loginId = '';
  String get loginId => _loginId;
  set loginId(String value) {
    if (_isValidUserId(value)) {
      _loginId = value;
      notifyListeners();
    }
  }
  String _loginPw = '';
  String get loginPw => _loginPw;
  set loginPw(String value) {
    if (_isValidUserPw(value)) {
      _loginPw = value;
      notifyListeners();
    }
  }

  bool get enableLoginButton => _loginId.isNotEmpty && _loginPw.isNotEmpty;
  void login() => throw UnimplementedError();
  bool _isValidUserId(String value) => true;
  bool _isValidUserPw(String value) => value.length >= 5;
}

이전 코드인 LoginScreen에서 정보를 표시하는 부분을 제외한 나머지 로직만 따로 분리한 ViewModel을 만들어 보았다.
가능하면 StatefulWidget으로만 만들었던 코드와 비교해보기 바란다. 단순히  변수와 함수를 분리시킨 것처럼 보일지 모르겠지만 이런 분리는 개발과 테스트에 큰 이점을 준다. 

이제 로직을 분리하였으니 이 부분만 따로 테스트를 해보자. 

StatefullWidget로 만든 코드를 테스트했던 것과 동일한  인풋값에 대한 유효성과 로그인 버튼의 활성화 여부 대해서 테스트를 작성해 보자.

   test('"enabledLoginButton" must be true only ....', ()  {
      final loginViewModel = LoginViewModel();
      expect(loginViewModel.enableLoginButton, false);
      loginViewModel.loginId = 'user01@example.com';
      loginViewModel.loginPw = 'pw';
      expect(loginViewModel.enableLoginButton, false);
      loginViewModel.loginPw = 'pw0001';
      expect(loginViewModel.enableLoginButton, true);
    });

 

이전 테스트 코드에 비해 좀 더 명확하고 간결해진 것을 알 수 있다. 

UI와 UI로직을 분리하면 각자의 역할이 명확해지기 때문에 의미를 파악하기 좋은 코드가 되고, 

테스트 코드 역시 좀 더 명확하게 작성할 수 있다.  
(특히 위젯에 대한 의존 없이 기능을 테스트할 수 있다는 것에 주목하자)

 

LoginView와 테스트 코드 

이제 로직을 분리시키고 남은 View부분을 다음과 같이 작성해 보자.

class LoginView extends StatelessWidget {
  final LoginViewModel viewModel;
  const LoginView({super.key, required this.viewModel});
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: viewModel,
      builder: (BuildContext context, Widget? child) {
        return Scaffold(
          body: Column(
            children: [
              TextFormField(
                initialValue: viewModel.loginId,
                onChanged: (value) => viewModel.loginId = value,
              ),
              TextFormField(
                initialValue: viewModel.loginPw,
                onChanged: (value) => viewModel.loginPw = value,
              ),
              TextButton(
                  onPressed: viewModel.enableLoginButton ? () => viewModel.login() : null,
                  child: const Text('Login'))
            ],
          ),
        );
      },
    );
  }
}

 

이전 Statefulwidget으로 작성되었던 부분이 StatelesWidget으로 변경되었고,  

ViewModel에 저장된 상태를 화면에 표시하는 것에 중점을 두고 있다.

이전 코드에 비해서 코드가 깔끔해지고 의미가 명확해 짐을 알 수 있다.  

AnimatedBuilder를 이용해서 viewModel의 변화를 감지 한 후 그 하위 builder 부분이 다시 생성되는 코드를 
Provider나 Reverpod, Bloc, Cubit, GetX와 같은 상태 관리 라이브러리를 활용하는 것으로 변경할 수 있도 있다. 
하지만 특정 상태 관리 라이브러리에 의존적인 코드를 배제하고 현재 설명하고 하고자 하는 의미를 명확하게 하기 위해서 Flutter에서 기본으로 제공하는 AnimatedBuilder을 사용했다. 
(물론 별도의 상태관리 라이브러리 없이 AnimatedBuilder을 활용해서 앱을 만드는것도 가능하다.)


위 코드에서 눈여겨 볼만한 부분은 ViewModel을 생성자를 통해서 외부에서부터 주입받는 부분이다. 

이렇게 ViewModel을 내부에서 생성하지 않고 외부에서 주입받아 사용하게 되면 ViewModel를 추상화한 후 Mock객체를 이용해서 View을 테스트하는 것이 가능하다. 

이제 View는 ViewModel에 직접 의존 없이 자체 적으로 테스트도 가능한다.
(예를 들면 특정 상태로 초기화된 MockViewModel을 주입한 후 화면을 원하는 모습대로 정상적으로 표시 가능한지 확인할 수 있다)

 

아래 코드와 같이  MockViewModel를 주입한 후 View만 따로 테스트 코드를 작성할 수 있다. 

 testWidgets('Should enable the login button when viewmodel.enabledLoginButton is true',
        (WidgetTester tester) async {
      final mockViewModel = MockLoginViewModel();
      when(mockViewModel.enableLoginButton).thenReturn(true);
      when(mockViewModel.loginId).thenReturn('user0001');
      when(mockViewModel.loginPw).thenReturn('pw0001');
      var myWidget = MaterialApp(home: LoginView(viewModel: mockViewModel));
      await tester.pumpWidget(myWidget);
      var loginButton = tester.widget<TextButton>(find.byType(TextButton));
      expect(loginButton.enabled, true);
    });

 

MockViewModel를 만들어서 실제 구현된 ViewModel과 상관없이 View에 대한 테스트가 가능하다는 것에 주목하자. 

LoginViewModel를 추상화한 후 MockViewModel를 만들어서 실제 구현된 구현체와 상관없이 View를 테스트하였다.

 

테스트 가능한 최종 코드 

이제 UI의 상태와 로직이 추상화된 LoginViewModel,  LoginViewModel의 구현체 LoginViewModelImpl, UI를 표시하는 LoginView가 완성되었고 코드는 아래와 같다 

/// LoginViewModel
abstract class LoginViewModel implements Listenable {
  String get loginId;
  set loginId(String value);
  String get loginPw;
  set loginPw(String value);
  bool get enableLoginButton;
  void login();
}

/// LoginViewModelImpl
class LoginViewModelImpl with ChangeNotifier implements LoginViewModel {
  String _loginId = '';
  @override
  String get loginId => _loginId;
  @override
  set loginId(String value) {
    if (_isValidUserId(value)) {
      _loginId = value;
      notifyListeners();
    }
  }

  String _loginPw = '';
  @override
  String get loginPw => _loginPw;
  @override
  set loginPw(String value) {
    if (_isValidUserPw(value)) {
      _loginPw = value;
      notifyListeners();
    }
  }

  @override
  bool get enableLoginButton => _loginId.isNotEmpty && _loginPw.isNotEmpty;

  @override
  void login() => throw UnimplementedError();

  bool _isValidUserId(String value) => true;
  bool _isValidUserPw(String value) => value.length >= 5;
}

/// LoginView
class LoginView extends StatelessWidget {
  final LoginViewModel viewModel;
  const LoginView({super.key, required this.viewModel});
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: viewModel,
      builder: (BuildContext context, Widget? child) {
        return Scaffold(
          body: Column(
            children: [
              TextFormField(
                initialValue: viewModel.loginId,
                onChanged: (value) => viewModel.loginId = value,
              ),
              TextFormField(
                initialValue: viewModel.loginPw,
                onChanged: (value) => viewModel.loginPw = value,
              ),
              TextButton(
                  onPressed: viewModel.enableLoginButton ? () => viewModel.login() : null,
                  child: const Text('Login'))
            ],
          ),
        );
      },
    );
  }
}

/// ViewModel 테스트 
test('"enabledLoginButton" must be true only ....', ()  {
  final loginViewModel = LoginViewModelImpl();
  expect(loginViewModel.enableLoginButton, false);
  loginViewModel.loginId = 'user01@example.com';
  loginViewModel.loginPw = 'pw';
  expect(loginViewModel.enableLoginButton, false);
  loginViewModel.loginPw = 'pw0001';
  expect(loginViewModel.enableLoginButton, true);
});

/// View Widget test
testWidgets('Should enable the login button when viewmodel.enabledLoginButton is true',
      (WidgetTester tester) async {
  final mockViewModel = MockLoginViewModel();
  when(mockViewModel.enableLoginButton).thenReturn(true);
  when(mockViewModel.loginId).thenReturn('user0001');
  when(mockViewModel.loginPw).thenReturn('pw0001');
  var myWidget = MaterialApp(home: LoginView(viewModel: mockViewModel));
  await tester.pumpWidget(myWidget);
  var loginButton = tester.widget<TextButton>(find.byType(TextButton));
  expect(loginButton.enabled, true);
});

처음 StatefulWidget으로 작성한 코드 보다 조금 더 길어진 것처럼 보이지만 

코드의 의미는 더 명확해졌고 테스트하기 좋은 코드가 되었다. 

또한 ViewModel의 추상화를 통해 저주순 구현체의 구현과 상관없이 View 분리해서 테스트할 수 있으며, 

ViewModel 역시 View와 관계없이 테스트하는 가능해졌다. 

 

마치며 

이미 많은 개발자들이 무의식 중에 혹은 이미 짜인 코드를 반복하면서 위에서 보여준 예시와 같은 구조로 개발을 하고 있을 것이라 생각합니다. 

테스트하기 좋은 코드를 만들기 위해서는 각 레이어 혹은 모듈의 역할을 명확하게 해야 하고  추상화 한 후 결합도를 낮춰서 의존하는 구현체의 구현과 상관없이 테스트가 가능해야 해야 합니다.

특히 UI를 포함하는 코드의 경우 테스트 하기 까다롭기 때문에 가능하면 정보를 표시하는 부분과 로직을 명확하게 구분할 필요가 있습니다.

위에서는 View와 ViewModel를 분리하고 구조화하는 것에 대한 예시를 보여 주었지만 역할을 분리시키고 추상화를 하는 과정을 통해 ViewModel의 기능들을 다른 Layer (DomainLayer나 DataLayer)로 분리시킬 수 있습니다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함