일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 플러터
- Same parameter
- 플러터 테스트
- SOLID 원칙
- 토큰갱신
- 안드로이드
- 2D graphics library
- Refresh Tocken
- 테스트 주도 개발론
- 배움순서
- 다트 테스트
- 2D 그래픽 라이브러리
- refresh 토큰
- 에러 메시지를 잘보자 ^^
- pubspec.yaml
- dart test
- TDD 개발 방법론
- 8시간 삽질
- 객체 지향 설계
- 다트
- 인코딩방지
- pubspec
- Flutter
- Parameter specified as non-null is null
- retorift
- widget test
- 안드로이드를 위한
- Android
- permission_handler
- dart
- Today
- Total
Landroid
[Flutter] TDD 사용하기 본문
플러터에는 크게 3가지의 테스팅 기법이 있습니다. 유닛 테스트, 위젯 테스트, 통합 테스트.
하지만 이것을 가지고 TDD를 작성하기는 매우 어렵습니다.
(플러터 TDD 정보가 너무 부족해....)
그래서 해외 자료들과 공식문서, 국내 블로그(특히 티스토리)를 참고하여 플러터에서 TDD를 적용하는 방법에 대해 설명하겠습니다.
1. 유닛 테스트 (UnitTest)
유닛 테스트, 단위 테스트라고 불리는 이 테스팅 기법은 메소드, 클래스 같은 작은 단위를 테스트할 때 쓰입니다.
하지만 서버통신이나 DB 접근이 필요한 테스트일 경우, 의존성 때문에 작성하기 더 어려워집니다.
그래서 Mockito 같은 테스트 프레임워크로 의존성을 줄이고 테스트를 작성할 수 있습니다.
유닛 테스트를 구현하기 위해서는 기본적인 테스트 패키지인 'test' 패키지를 추가해서 구현하여야 합니다.
추가로 의존성을 필요한다면 'mockito' 패키지를 추가하여야 합니다.
(공식문서가 여기보다 더 잘 정리되어 있다는 건 비밀)
심화
2. 위젯 테스트 (WidgetTest)
위젯 테스트는 간단히 말해서 UI 테스트라고 부를 수 있습니다.
플러터에 위젯 테스트는 위젯 UI가 예상대로 보여지는지 상호작용이 정상적으로 동작하는지 검증하는 테스트입니다.
위젯 테스트를 구현하기 위해서는 기본적인 위젯 테스트 패키지인 'flutter_test' 패키지를 추가해서 구현하여야 합니다.
UI 테스트라고 해서 구현하기 어렵다고 생각하는 사람이 많은데 (사실 어려운 거 맞아)
간단하게 다음과 같은 메커니즘을 가지고 있다.
1. WidgetTester에 pumpWidget으로 테스트하고 싶은 위젯을 추가한다.
2-1. find나 key를 활용해서 추가한 위젯에서 테스트하고 싶은 자식 위젯을 찾는다.
2-2. WidgetTester으로 탭, 드래그, 키보드 입력과 같은 행동으로 위젯의 동작을 정의한다.
3. expect함수로 해당 자식 위젯이 원하는 결과를 보이고 있는지 검사한다.
3. 통합 테스트 (IntergrationTest)
위에서 설명하듯이 통합 테스트는 개별 클래스, 함수, 위젯 등 각 개별 요소들이 실제 기기에서 어떻게 같이 어우러져 동작하는지 검증하는 테스팅 기법입니다.
통합 테스트를 구현하기 위해서는 기본적인 통합 테스트 패키지인 'flutter_driver' 패키지를 추가해서 구현하여야 합니다.
TDD란?
1. TDD는 간단하게 비지니스 코드를 작성하기 전에 테스트 코드를 먼저 만드는 것 개발 방법론입니다.
2. TDD는 최대한 빨리 실패를 하고 보완해가며 조금 더 코드가 완벽해지도록 개발되는 방식입니다.
3. 플러터에서는 비지니스 코드는 물론 UI 코드 조차 테스트를 먼저 만들고 구현해 나갑니다.
TDD 작성 예시
위 블로그는 플러터 TDD에 대해 가장 잘 정리되어있습니다.
그래서 해당 블로그를 번역하여 플러터에서 TDD를 어떻게 적용하여 개발하는지 예제와 함께 각 단계를 설명하도록 하겠습니다.
(각 단계는 절대적인 것이 아니며 상황에 따라 순서가 변경될 수 있습니다.)
Step 0: 노트앱
간단하게 노트앱을 플러터와 TDD로 구현하겠습니다.
이제 아래 4가지 기능들을 가진 앱을 만들 것입니다.
- 노트를 리스트로 보여주기
- 노트 생성
- 노트 수정
- 노트 삭제
* 테스트 코드 보기 어려우시면 제목이라도 보셔서 무엇을 하는지 정도 확인하시길 바랍니다.
Step 1: 플러터에서 사용할 데이터 모델에 대한 TDD 테스트 작성
앱의 가장 핵심적인 데이터 모델에 대한 테스트 코드를 만들겠습니다.
현재 만들 앱은 노트앱이기 때문에 노트에 대한 데이터 클래스를 만들기 위해 CRUD가 가능해야 하고 제목과 본문이 필수적으로 있어야 합니다.
이제 위에 두 조건을 참고하여 테스트 코드부터 작성하겠습니다.
(추가로 해당 데이터 모델 클래스 이름은 NotesCubit입니다.)
import 'package:test/test.dart';
void main() {
group('Notes Cubit', () {
test('default is empty', () {
var cubit = NotesCubit();
expect(cubit.state.notes, []);
});
test('add notes', () {
var title = 'title';
var body = 'body';
var cubit = NotesCubit();
cubit.createNote(title, body);
expect(cubit.state.notes.length, 1);
expect(cubit.state.notes.first, Note(1, title, body));
});
test('delete notes', () {
var cubit = NotesCubit();
cubit.createNote('title', 'body');
cubit.createNote('another title', 'another body');
cubit.deleteNote(1);
expect(cubit.state.notes.length, 1);
expect(cubit.state.notes.first.id, 2);
});
test('update notes', () {
var cubit = NotesCubit();
cubit.createNote('title', 'body');
cubit.createNote('another title', 'another body');
cubit.createNote('yet another title', 'yet another body');
var newTitle = 'my cool note';
var newBody = 'my cool note body';
cubit.updateNote(2, newTitle, newBody);
expect(cubit.state.notes.length, 3);
expect(cubit.state.notes[1], Note(2, newTitle, newBody));
});
});
}
Step 2: 데이터 모델 구현
테스트 코드를 만들면 빨간 줄이 뜰 것입니다.
(보통은 테스트 코드에서 빨간 줄 뜨면 즉시 클래스 만들어서 구현하는 게 일반적이지만 -_-)
그래서 빨간 줄을 제거하기 위해 데이터 모델을 구현해야 합니다.
테스트에서 필요한 정보들을 구현함으로써 최소한의 구현으로 코드가 깔끔해지는 혜택을 받을 수 있습니다.
(나만 안 느껴지는 거니? ^^)
// lib/mode/note.dart
// 핵심 데이터 모델
class Note extends Equatable {
final int id;
final String title;
final String body;
Note(this.id, this.title, this.body);
@override
List<Object> get props => [id, title, body];
}
// lib/cubit/notes_cubit.dart
// 데이터 모델의 상태
class NotesState {
final UnmodifiableListView notes;
NotesState(this.notes);
}
// 데이터 모델을 관리하는 컨트롤러
class NotesCubit extends Cubit<NotesState> {
List _notes = [];
int autoIncrementId = 0;
NotesCubit() : super(NotesState(UnmodifiableListView([])));
void createNote(String title, String body) {
_notes.add(Note(++autoIncrementId, title, body));
emit(NotesState(UnmodifiableListView(_notes)));
}
void deleteNote(int id) {
_notes = _notes.where((element) => element.id != id).toList();
emit(NotesState(UnmodifiableListView(_notes)));
}
void updateNote(int id, String title, String body) {
var noteIndex = _notes.indexWhere((element) => element.id == id);
_notes.replaceRange(noteIndex, noteIndex + 1, [Note(id, title, body)]);
emit(NotesState(UnmodifiableListView(_notes)));
}
}
Step 3: UI 테스트 코드 작성
비지니스 코드뿐만 아니라 UI 코드도 예외 없이 테스트 코드부터 작성해야 합니다.
하지만 이런 걱정을 할 수 있습니다.
???: 으악 화면마다 UI가 겁나게 복잡한데 어떻게 테스트부터 작성할 수 있는 거야!!!!
우리는 UI가 복잡해지면서 위젯이 계속 계단을 만들어가는 모습을 눈살이 찌푸려 가는데도 가만히 놔둘 리가 없죠 ^^
그래서 UI를 핵심 기능이나 위젯 단위로 분리해서 구현합니다. (설마 아직도 한 파일 안에 코드를 다 작성하는 사람은 없겠지?)
그래야 테스트하기도 쉽고 UI 구현하는데도 문제가 없습니다.
일단 우리가 구현할 UI는 굉장히 단순하기 때문에 화면 단위로 테스트 코드를 작성하겠습니다.
// test/widget/home_page_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Home Page', () {
_pumpTestWidget(WidgetTester tester, NotesCubit cubit) => tester.pumpWidget(
MaterialApp(
home: MyHomePage(
title: 'Home',
notesCubit: cubit,
),
),
);
testWidgets('empty state', (WidgetTester tester) async {
await _pumpTestWidget(tester, NotesCubit());
expect(find.byType(ListView), findsOneWidget);
expect(find.byType(ListTile), findsNothing);
});
testWidgets('updates list when a note is added',
(WidgetTester tester) async {
var notesCubit = NotesCubit();
await _pumpTestWidget(tester, notesCubit);
var expectedTitle = 'note title';
var expectedBody = 'note body';
notesCubit.createNote(expectedTitle, expectedBody);
notesCubit.createNote('another note', 'another note body');
await tester.pump();
expect(find.byType(ListView), findsOneWidget);
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(expectedTitle), findsOneWidget);
expect(find.text(expectedBody), findsOneWidget);
});
testWidgets('updates list when a note is deleted', (WidgetTester tester) async {
var notesCubit = NotesCubit();
await _pumpTestWidget(tester, notesCubit);
var expectedTitle = 'note title';
var expectedBody = 'note body';
notesCubit.createNote(expectedTitle, expectedBody);
notesCubit.createNote('another note', 'another note body');
await tester.pump();
notesCubit.deleteNote(1);
await tester.pumpAndSettle();
expect(find.byType(ListView), findsOneWidget);
expect(find.byType(ListTile), findsOneWidget);
expect(find.text(expectedTitle), findsNothing);
});
});
}
Step 4: HomePage UI 구현
ㅈㄱㄴ (말 그대로 노트를 리스트 뷰로 보여주는 UI를 구현합니다.)
// lib/home_page.dart
class MyHomePage extends StatelessWidget {
final NotesCubit notesCubit;
final String title;
MyHomePage({Key key, this.title, this.notesCubit}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: BlocBuilder<NotesCubit, NotesState>(
cubit: notesCubit,
builder: (context, state) => ListView.builder(
itemCount: state.notes.length,
itemBuilder: (context, index) {
var note = state.notes[index];
return ListTile(
title: Text(note.title),
subtitle: Text(note.body),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'Add',
child: Icon(Icons.add),
),
);
}
}
Step 5: 새로운 페이지 생성
갑자기 뜬금없이 왜 새 페이지를 만드는 이유는앞 단계에서 FAB을 눌렀을 때 노트를 만드는 것을 예상할 수 있습니다.
이렇게 화면 간의 이동이 있을 때 테스트 코드를 작성하고 새로운 UI를 만듭니다.그래서 아래와 같이 FAB을 눌렀을 때 테스트 코드를 추가하고 HomePage에 화면을 이동하도록 Navigator를 사용합니다.
// test/widget/home_page_test.dart
// ...
testWidgets('navigate to note page', (WidgetTester tester) async {
var notesCubit = NotesCubit();
await _pumpTestWidget(tester, notesCubit);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(find.byType(NotePage), findsOneWidget);
});
// ...
// lib/note_page.dart
class NotePage extends StatelessWidget {
final NotesCubit notesCubit;
MyHomePage({Key key, this.notesCubit}) : super(key: key);
@override
Widget build(BuildContext context) => Container();
}
// lib/home_page.dart
// Here we add a new method to our widget
// ...
_goToNotePage(BuildContext context) => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NotePage(
notesCubit: notesCubit,
),
),
);
// ...
// And update our floatingActionButton
floatingActionButton: FloatingActionButton(
onPressed: () => _goToNotePage(context),
tooltip: 'Add',
child: Icon(Icons.add),
),
Step 6: 노트 생성을 위한 테스트 코드 작성
노트를 작성하기 위해서는 많은 것이 필요하지 않습니다.
노트를 생성하는 NotePage에서는 단순히 텍스트 필드 두 개를 두어도 충분합니다.
이제 두 텍스트 필드에 대한 테스트 코드를 작성하겠습니다.
// test/widget/note_page_test.dart
void main() {
group('Note Page', () {
_pumpTestWidget(WidgetTester tester, NotesCubit cubit) =>
tester.pumpWidget(
MaterialApp(
home: NotePage(
notesCubit: cubit
),
),
);
testWidgets('empty state', (WidgetTester tester) async {
await _pumpTestWidget(tester, NotesCubit());
expect(find.text('Enter your text here...'), findsOneWidget);
expect(find.text('Title'), findsOneWidget);
});
testWidgets('create note', (WidgetTester tester) async {
var cubit = NotesCubit();
await _pumpTestWidget(tester, cubit);
await tester.enterText(find.byKey(ValueKey('title')), 'hi');
await tester.enterText(find.byKey(ValueKey('body')), 'there');
await tester.tap(find.byType(RaisedButton));
await tester.pumpAndSettle();
expect(cubit.state.notes, isNotEmpty);
var note = cubit.state.notes.first;
expect(note.title, 'hi');
expect(note.body, 'there');
expect(find.byType(NotePage), findsNothing);
});
});
}
Step 7: NotePage UI 구현
테스트를 구현했으니 UI를 구현해야죠.
// lib/note_page.dart
class NotePage extends StatefulWidget {
final NotesCubit notesCubit;
const NotePage({Key key, this.notesCubit}) : super(key: key);
@override
_NotePageState createState() => _NotePageState();
}
class _NotePageState extends State {
final _titleController = TextEditingController();
final _bodyController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
key: ValueKey('title'),
controller: _titleController,
autofocus: true,
decoration: InputDecoration(hintText: 'Title'),
),
Expanded(
child: TextField(
key: ValueKey('body'),
controller: _bodyController,
keyboardType: TextInputType.multiline,
maxLines: 500,
decoration:
InputDecoration(hintText: 'Enter your text here...'),
),
),
RaisedButton(
child: Text('Ok'),
onPressed: () => _finishEditing(),
)
],
),
),
);
}
_finishEditing() {
widget.notesCubit.createNote(_titleController.text, _bodyController.text);
Navigator.pop(context);
}
@override
void dispose() {
super.dispose();
_titleController.dispose();
_bodyController.dispose();
}
}
Step 8: HomePage와 NotePage에 대한 테스트 코드 작성
제목이 당황스럽지만 그냥 노트 수정 기능을 구현하는 단계입니다.
(왜 이렇게 제목을 지었지?)
// lib/test/widget/home_page_test.dart
// ...
// In our home page tests we can assert the list tapping action
testWidgets('navigate to note page in edit mode',
(WidgetTester tester) async {
var notesCubit = NotesCubit();
await _pumpTestWidget(tester, notesCubit);
var expectedTitle = 'note title';
var expectedBody = 'note body';
notesCubit.createNote(expectedTitle, expectedBody);
await tester.pump();
await tester.tap(find.byType(ListTile));
await tester.pumpAndSettle();
expect(find.byType(NotePage), findsOneWidget);
expect(find.text(expectedTitle), findsOneWidget);
expect(find.text(expectedBody), findsOneWidget);
});
// ...
// lib/test/widget/home_page_test.dart
void main() {
group('Note Page', () {
_pumpTestWidget(WidgetTester tester, NotesCubit cubit, {Note note}) =>
tester.pumpWidget(
MaterialApp(
home: NotePage(
notesCubit: cubit,
note: note,
),
),
);
// ...
testWidgets('create in edit mode', (WidgetTester tester) async {
var note = Note(1, 'my note', 'note body');
await _pumpTestWidget(tester, NotesCubit(), note: note);
expect(find.text(note.title), findsOneWidget);
expect(find.text(note.body), findsOneWidget);
});
testWidgets('edit note', (WidgetTester tester) async {
var cubit = NotesCubit()..createNote('my note', 'note body');
await _pumpTestWidget(tester, cubit, note: cubit.state.notes.first);
await tester.enterText(find.byKey(ValueKey('title')), 'hi');
await tester.enterText(find.byKey(ValueKey('body')), 'there');
await tester.tap(find.byType(RaisedButton));
await tester.pumpAndSettle();
expect(cubit.state.notes, isNotEmpty);
var note = cubit.state.notes.first;
expect(note.title, 'hi');
expect(note.body, 'there');
expect(find.byType(NotePage), findsNothing);
});
});
}
// lib/home_page.dart
class MyHomePage extends StatelessWidget {
// ...
// Adding the ListTile tap action:
return ListTile(
title: Text(note.title),
subtitle: Text(note.body),
onTap: () => _goToNotePage(context, note: note),
);
// ...
// Sending the Note to the next page:
_goToNotePage(BuildContext context, {Note note}) => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NotePage(
notesCubit: notesCubit,
note: note,
),
),
);
}
// lib/note_page.dart
class NotePage extends StatefulWidget {
final NotesCubit notesCubit;
final Note note;
const NotePage({Key key, this.notesCubit, this.note}) : super(key: key);
@override
_NotePageState createState() => _NotePageState();
}
class _NotePageState extends State {
final _titleController = TextEditingController();
final _bodyController = TextEditingController();
@override
void initState() {
super.initState();
if (widget.note == null) return;
_titleController.text = widget.note.title;
_bodyController.text = widget.note.body;
}
// ...
_finishEditing() {
if (widget.note != null) {
widget.notesCubit.updateNote(
widget.note.id, _titleController.text, _bodyController.text);
} else {
widget.notesCubit.createNote(_titleController.text, _bodyController.text);
}
Navigator.pop(context);
}
// ...
}
Step 9: 삭제 기능 구현
여기까지 오셨으면 대강 기능을 구현할 때마다 플러터에서 TDD로 어떻게 구현하는지 감이 오실 거라고 생각합니다.
// test/widget/note_page_test.dart
void main() {
group('Note Page', () {
_pumpTestWidget(WidgetTester tester, NotesCubit cubit, {Note note}) =>
tester.pumpWidget(
MaterialApp(
home: NotePage(
notesCubit: cubit,
note: note,
),
),
);
testWidgets('empty state', (WidgetTester tester) async {
await _pumpTestWidget(tester, NotesCubit());
expect(find.text('Enter your text here...'), findsOneWidget);
expect(find.text('Title'), findsOneWidget);
var widgetFinder = find.widgetWithIcon(IconButton, Icons.delete);
var deleteButton = widgetFinder.evaluate().single.widget as IconButton;
expect(deleteButton.onPressed, isNull);
});
testWidgets('create note', (WidgetTester tester) async {
var cubit = NotesCubit();
await _pumpTestWidget(tester, cubit);
await tester.enterText(find.byKey(ValueKey('title')), 'hi');
await tester.enterText(find.byKey(ValueKey('body')), 'there');
await tester.tap(find.byType(RaisedButton));
await tester.pumpAndSettle();
expect(cubit.state.notes, isNotEmpty);
var note = cubit.state.notes.first;
expect(note.title, 'hi');
expect(note.body, 'there');
expect(find.byType(NotePage), findsNothing);
});
testWidgets('create in edit mode', (WidgetTester tester) async {
var note = Note(1, 'my note', 'note body');
await _pumpTestWidget(tester, NotesCubit(), note: note);
expect(find.text(note.title), findsOneWidget);
expect(find.text(note.body), findsOneWidget);
var widgetFinder = find.widgetWithIcon(IconButton, Icons.delete);
var deleteButton = widgetFinder.evaluate().single.widget as IconButton;
expect(deleteButton.onPressed, isNotNull);
});
testWidgets('edit note', (WidgetTester tester) async {
var cubit = NotesCubit()..createNote('my note', 'note body');
await _pumpTestWidget(tester, cubit, note: cubit.state.notes.first);
await tester.enterText(find.byKey(ValueKey('title')), 'hi');
await tester.enterText(find.byKey(ValueKey('body')), 'there');
await tester.tap(find.byType(RaisedButton));
await tester.pumpAndSettle();
expect(cubit.state.notes, isNotEmpty);
var note = cubit.state.notes.first;
expect(note.title, 'hi');
expect(note.body, 'there');
expect(find.byType(NotePage), findsNothing);
});
testWidgets('delete note', (WidgetTester tester) async {
var cubit = NotesCubit()..createNote('my note', 'note body');
await _pumpTestWidget(tester, cubit, note: cubit.state.notes.first);
await tester.tap(find.byType(IconButton));
await tester.pumpAndSettle();
expect(cubit.state.notes, isEmpty);
expect(find.byType(NotePage), findsNothing);
});
});
}
// lib/note_page.dart
// ...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
icon: Icon(Icons.delete),
onPressed: widget.note != null ? _deleteNote : null,
)
],
),
// ...
}
// ...
_deleteNote() {
widget.notesCubit.deleteNote(widget.note.id);
Navigator.pop(context);
}
// ...
}
Reference
'플러터' 카테고리의 다른 글
[Flutter] State와 StatefulWidget을 분리한 이유 (3) | 2021.04.07 |
---|---|
[Flutter] 플러터 프로젝트에 .gitignore 추가하기 (1) | 2021.03.10 |
FutureBuilder에서 future 함수 중복 호출 방지 (0) | 2021.02.21 |
[Flutter] Skia가 뭐지? (0) | 2021.01.15 |
[플러터] 테스트 (0) | 2021.01.09 |