Slide 1

Slide 1 text

Compose UI 컴포넌트 설계와 테스트 김수현 / 우아한형제들

Slide 2

Slide 2 text

❏ 우아한형제들 Android Dev/Educator ❏ NEXTSTEP Educator/Code Reviewer ❏ DroidKnights Conference Organizer ❏ GDG Korea Android Organizer GitHub @wisemuji

Slide 3

Slide 3 text

이야기할 내용 ❏ Compose UI 컴포넌트 설계의 중요성 ❏ React로 살펴보는 선언형 UI 컴포넌트 설계 노하우 ❏ 사례로 알아보는 UI 컴포넌트 설계 팁 ❏ 사례로 알아보는 UI 컴포넌트 테스트

Slide 4

Slide 4 text

다루지 않는 내용 ❏ UI 테스트 프레임워크 사용법 ❏ 웹 프론트엔드 및 React 개념 ❏ 컴포넌트 아키텍처 패턴 ❏ Compose Stability “구현체가 아닌 인터페이스에 의존"

Slide 5

Slide 5 text

Compose UI 컴포넌트 설계의 중요성

Slide 6

Slide 6 text

컴포넌트를 나누는 기준 ❏ 재사용 가능성 ❏ 테스트 가능성 ❏ 하나의 역할/책임 ❏ 변경에 유연함 ❏ …

Slide 7

Slide 7 text

컴포넌트를 나누는 기준 ❏ 재사용 가능성 ❏ 테스트 가능성 ❏ 하나의 역할/책임 ❏ 변경에 유연함 ❏ …

Slide 8

Slide 8 text

❏ Case1: 화면 단위 구현 ❏ Case2: 컴포넌트 단위 구현 드로이드나이츠 앱 github.com/droidknights/DroidKnightsApp

Slide 9

Slide 9 text

Case1: 화면 단위 구현 SessionScreen

Slide 10

Slide 10 text

Case2: 컴포넌트 단위 구현 SessionScreen SessionTopAppBar SessionContent SessionItem SessionCard RoomTitle

Slide 11

Slide 11 text

변경사항이 생긴다면? ❏ 새로운 기능이 추가된다면? ❏ 디자인 스타일이 바뀐다면? ❏ 아이템의 배치가 변경된다면?

Slide 12

Slide 12 text

변경사항이 생긴다면? ❏ 새로운 기능이 추가된다면? ❏ 디자인 스타일이 바뀐다면? ❏ 아이템의 배치가 변경된다면? 북마크 기능 추가

Slide 13

Slide 13 text

Case1: 화면 단위 구현 SessionScreen

Slide 14

Slide 14 text

Case1: 화면 단위 구현 SessionScreen 어디부터 건드려야 하지? 🤯 변경사항이 전체 화면에 전파

Slide 15

Slide 15 text

Case2: 컴포넌트 단위 구현 SessionScreen SessionTopAppBar SessionContent SessionItem SessionCard RoomTitle

Slide 16

Slide 16 text

Case2: 컴포넌트 단위 구현 SessionScreen SessionTopAppBar SessionContent SessionItem SessionCard RoomTitle 특정 컴포넌트에만 변경사항 전파

Slide 17

Slide 17 text

(중략)… https://android.googlesource.com/…/compose-component-api-guidelines.md

Slide 18

Slide 18 text

(중략)… https://android.googlesource.com/…/compose-component-api-guidelines.md 하나의 컴포넌트는 하나의 문제만 해결해야 한다. 컴포넌트가 하나 이상의 문제를 해결한다면 하위 계층 또는 하위 컴포넌트로 분할하는 것을 고려하라. 작고 간결한 API로 디자인할수록 사용하기 쉽고, 컴포넌트 인터페이스를 명확하게 이해할 수 있다.

Slide 19

Slide 19 text

UI 컴포넌트 설계의 중요성 ❏ Android View에서는 화면(Activity, Fragment) 단위의 구현이 일반적이였으나, Compose에서는 선언적으로 작성된 컴포넌트로 쪼개어 설계할 수 있다. ❏ 적절한 컴포넌트 단위를 나누어 변경사항에 유연한 설계를 지향하자. ❏ 결합도를 낮추고 응집도를 높이자.

Slide 20

Slide 20 text

React로 살펴보는 선언형 UI 컴포넌트 설계 노하우

Slide 21

Slide 21 text

선언형 UI 패러다임 선배들 ❏ React - 2013 JSConf US 오픈소스화 ❏ Flutter - 2015 다트 개발자 서밋 첫 공개 ❏ SwiftUI - 2019 WWDC 첫 소개 ❏ Jetpack Compose - 2021 Stable 버전 정식 출시

Slide 22

Slide 22 text

선언형 UI 패러다임 선배들 ❏ React - 2013 JSConf US 오픈소스화 ❏ Flutter - 2015 다트 개발자 서밋 첫 공개 ❏ SwiftUI - 2019 WWDC 첫 소개 ❏ Jetpack Compose - 2021 Stable 버전 정식 출시

Slide 23

Slide 23 text

React - 컴포넌트 설계 노하우 多

Slide 24

Slide 24 text

Jetpack Compose는?

Slide 25

Slide 25 text

Jetpack Compose는? 선언형 UI 선배들에 비해 아직은 ʻ좋은 컴포넌트 설계’에 관한 연구가 압도적으로 부족

Slide 26

Slide 26 text

React 컴포넌트 설계 Best Practices React 개발자들이 공유하는 핵심 설계 원칙은 Compose UI에도 적용 가능 ❏ 함수형 컴포넌트 ❏ 작고 재사용 가능한 컴포넌트 ❏ UI와 비즈니스 로직의 분리 ❏ Atomic Design

Slide 27

Slide 27 text

선언형 UI 패러다임은 서로 밀접하게 닿아있다 상대적으로 역사가 긴 선배 프레임워크 개발자들의 노하우를 참고할 수 있을 것이다.

Slide 28

Slide 28 text

사례로 알아보는 UI 컴포넌트 설계 팁

Slide 29

Slide 29 text

컴포넌트의 재사용성 비즈니스의 확장과 변경을 고려한 설계

Slide 30

Slide 30 text

컴포넌트의 재사용성 비즈니스의 확장과 변경을 고려한 설계

Slide 31

Slide 31 text

중복은 항상 제거되어야 할까? ❏ 외관상 중복된 코드라도 수정의 이유가 다르면 실제로는 중복이 아니다. ❏ 중복 제거를 강조하는 DRY(Don't repeat yourself) 원칙을 과도하게 적용함으로써 발생하는 의존성 문제와 조건문 추가로 인한 복잡성 증가는 오히려 유지보수를 어렵게 할 수 있다.

Slide 32

Slide 32 text

@Composable fun KnightsTopAppBar( @StringRes titleRes: Int, ... isSessionDetail: Boolean, ) { ... Row(Modifier.align(Alignment.CenterEnd)) { if (isSessionDetail) BookmarkToggleButton(...) } }

Slide 33

Slide 33 text

@Composable fun KnightsTopAppBar( @StringRes titleRes: Int, ... isSessionDetail: Boolean, ) { ... Row(Modifier.align(Alignment.CenterEnd)) { if (isSessionDetail) BookmarkToggleButton(...) } }

Slide 34

Slide 34 text

@Composable fun KnightsTopAppBar( @StringRes titleRes: Int, ... isSessionDetail: Boolean, isTimetable: Boolean, isSomethingElse: Boolean, ) { ... Row(Modifier.align(Alignment.CenterEnd)) { if (isSessionDetail) BookmarkToggleButton(...) if (isTimetable) EditModeButton(...) if (isSomethingElse) SomethingElseButton(...) } } 컴포넌트끼리 강하게 결합, 직관적이지 않은 API 인터페이스 X

Slide 35

Slide 35 text

중복은 항상 제거되어야 할까? ❏ 조건문 추가는 컴포넌트가 수정되어야 하는 이유가 다름을 의미한다. ❏ 이는 중복 제거와 재사용 대상이 아니라 복잡성의 시작이다 ❏ 조건문이 없는 컴포넌트를 만들기는 쉽지 않지만 조건문의 추가가 컴포넌트에 어떤 영향을 주는지 이해해야 한다. ❏ 재사용성을 고려할 때는 공통적인 요소만을 추출하고, 컴포넌트 고유의 속성은 외부에서 전달하는 방식을 고려해야 한다.

Slide 36

Slide 36 text

@Composable fun KnightsTopAppBar( @StringRes titleRes: Int, ... actionButtons: @Composable () -> Unit = {}, ) { ... Row(Modifier.align(Alignment.CenterEnd)) { actionButtons() } } Composition(조합) O

Slide 37

Slide 37 text

enum class TopAppBarNavigationType { Back, Close } @Composable fun KnightsTopAppBar( @StringRes titleRes: Int, ... navigationType: TopAppBarNavigationType, actionButtons: @Composable () -> Unit = {}, ) { ... if (navigationType == TopAppBarNavigationType.Back) { icon( Modifier.align(Alignment.CenterStart), Icons.AutoMirrored.Filled.ArrowBack ) } Row(Modifier.align(Alignment.CenterEnd)) { actionButtons() } }

Slide 38

Slide 38 text

1. 재사용 가능한 컴포넌트 2. 결합도를 낮추고 응집도를 높인 설계 3. 직관적인 API 디자인 -> 변경사항에 유연하게 대처

Slide 39

Slide 39 text

사례로 알아보는 UI 컴포넌트 테스트

Slide 40

Slide 40 text

Stateful vs Stateless 컴포넌트 일반적으로 다음과 같이 구분한다. ❏ 자체적으로 상태를 생성/보유/수정하는 Stateful 컴포넌트 ❏ 자체적으로 상태를 생성/보유/수정하지 않는 Stateless 컴포넌트

Slide 41

Slide 41 text

// Don’t @Composable fun StatefulComponent() { var username by remember { mutableStateOf("") } TextField( value = username, onValueChange = { username = it }, label = { Text("Username") } ) }

Slide 42

Slide 42 text

상태를 공유하거나 재사용하기 어렵게 만들고, 컴포넌트 간의 결합도를 높인다. 전제 조건(precondition) 설정이 어려워 테스트하기 까다롭다. // Don’t @Composable fun StatefulComponent() { var username by remember { mutableStateOf("") } TextField( value = username, onValueChange = { username = it }, label = { Text("Username") } ) }

Slide 43

Slide 43 text

// Do @Composable fun StatefulComponent() { var username by remember { mutableStateOf("") } StatelessComponent(username, onChange = { username = it }) } @Composable fun StatelessComponent(username: String, onChange: (String) -> Unit) { TextField( value = username, onValueChange = onChange, label = { Text("Username") } ) }

Slide 44

Slide 44 text

// Do @Composable fun StatefulComponent() { var username by remember { mutableStateOf("") } StatelessComponent(username, onChange = { username = it }) } @Composable fun StatelessComponent(username: String, onChange: (String) -> Unit) { TextField( value = username, onValueChange = onChange, label = { Text("Username") } ) } State Hoisting 상태를 상위 컴포넌트로 옮겨 여러 컴포넌트에서 공유하고, UI의 상태를 쉽게 관리하고 테스트할 수 있다.

Slide 45

Slide 45 text

Stateful vs Stateless 컴포넌트 일반적으로 다음과 같이 구분한다. ❏ 자체적으로 상태를 생성/보유/수정하는 Stateful 컴포넌트 ❏ 자체적으로 상태를 생성/보유/수정하지 않는 Stateless 컴포넌트

Slide 46

Slide 46 text

Stateful vs Stateless 컴포넌트 일반적으로 다음과 같이 구분한다. ❏ 자체적으로 상태를 생성/보유/수정하는 Stateful 컴포넌트 ❏ 자체적으로 상태를 생성/보유/수정하지 않는 Stateless 컴포넌트 상태와 상태를 표시하는 UI를 분리하여 Stateless 컴포넌트를 위주로 테스트하자.

Slide 47

Slide 47 text

좀 더 일반적인 이야기 “그래서 테스트 가능한 컴포넌트가 뭔데?” “컴포넌트 설계와 테스트가 어떤 연관이 있는걸까?”

Slide 48

Slide 48 text

컴포넌트 테스트 시나리오 컴포넌트 Contract 분석 컴포넌트에 기대하는 동작, 사용하기 적절한 상황 - 컴포넌트가 입력받는 파라미터는 무엇인가? - 어떤 전제 조건(precondition)을 설정할 수 있는가? - 어떤 Side effect가 발생하는가? 테스트가 필요한 항목 정의 불필요한 테스트 항목 제거

Slide 49

Slide 49 text

컴포넌트 테스트 시나리오 컴포넌트 Contract 분석 테스트가 필요한 항목 정의 불필요한 테스트 항목 제거 from: Design by Contract

Slide 50

Slide 50 text

컴포넌트 테스트 시나리오 컴포넌트 Contract 분석 컴포넌트 전체를 한 번에 테스트하기보다, 의미 있는 단위로 쪼개어진 컴포넌트를 각각 테스트한다. 세션 상세 정보 화면 예시: - SessionDetailUiState에 따라 로딩 UI 또는 세션 상세 UI(세션 제목, 내용, 발표자 정보, 태그, 시간)가 올바르게 표시되는지 확인 - 북마크 버튼 클릭 시 UI에 북마크 상태 변경이 반영되는지 확인 - 뒤로 가기 버튼 클릭 시 onBackClick 콜백 함수가 호출되는지 확인 ... 테스트가 필요한 항목 정의 불필요한 테스트 항목 제거

Slide 51

Slide 51 text

컴포넌트 테스트 시나리오 컴포넌트 Contract 분석 테스트하지 않아도 되는 항목 - 불필요한 컴포넌트 내부 구현은 테스트하지 않는다. (예: 이미지 로딩 방식) - 일반적으로 Compose UI의 레이아웃 및 스타일에 적합한 테스트 방법이 따로 있다(Preview, Snapshot Testing 등) (예: 각 버튼 컴포넌트가 선형으로 쌓였는지, 여백은 몇 dp인지) 테스트가 필요한 항목 정의 불필요한 테스트 항목 제거

Slide 52

Slide 52 text

Compose UI Testing 예시 시나리오: 북마크 아이콘 클릭 시 북마크 상태가 변경된다

Slide 53

Slide 53 text

@get:Rule val composeTestRule = createComposeRule() 1. 테스트 환경 세팅

Slide 54

Slide 54 text

@get:Rule val composeTestRule = createComposeRule() @Test fun 북마크_아이콘_클릭_시_북마크_상태가_변경된다() { var bookmarked by mutableStateOf(false) composeTestRule.setContent { KnightsTheme { SessionDetailTopAppBar( bookmarked = bookmarked, onClickBookmark = { bookmarked = !bookmarked }, onBackClick = { } ) } } } 1. 테스트 환경 세팅 2. 테스트를 위한 전제 조건 작성

Slide 55

Slide 55 text

@Test fun 북마크_아이콘_클릭_시_북마크_상태가_변경된다() { var bookmarked by mutableStateOf(false) composeTestRule.setContent { ... } val bookmarkCheckbox = SemanticsMatcher.expectValue( SemanticsProperties.Role, Role.Checkbox ) composeTestRule.onNode(bookmarkCheckbox) .performClick() } 1. 테스트 환경 세팅 2. 테스트를 위한 전제 조건 작성 3. 테스트 대상 레이아웃 찾기

Slide 56

Slide 56 text

1. 테스트 환경 세팅 2. 테스트를 위한 전제 조건 작성 3. 테스트 대상 레이아웃 찾기 @Test fun 북마크_아이콘_클릭_시_북마크_상태가_변경된다() { var bookmarked by mutableStateOf(false) composeTestRule.setContent { ... } val bookmarkCheckbox = SemanticsMatcher.expectValue( SemanticsProperties.Role, Role.Checkbox ) composeTestRule.onNode(bookmarkCheckbox) .performClick() } Layout Inspector -> Semantics Role 컴포넌트의 식별자를 명시하지 않더라도 컴포넌트의 역할 기반으로 구현체 가져오기

Slide 57

Slide 57 text

@Test fun 북마크_아이콘_클릭_시_북마크_상태가_변경된다() { var bookmarked by mutableStateOf(false) composeTestRule.setContent { ... } val bookmarkCheckbox = SemanticsMatcher.expectValue( SemanticsProperties.Role, Role.Checkbox ) composeTestRule.onNode(bookmarkCheckbox) .performClick() .assertIsOn() assert(bookmarked) } 1. 테스트 환경 세팅 2. 테스트를 위한 전제 조건 작성 3. 테스트 대상 레이아웃 찾기 4. 검증부 (Assertion)

Slide 58

Slide 58 text

1. 테스트 환경 세팅 2. 테스트를 위한 전제 조건 작성 3. 테스트 대상 레이아웃 찾기 4. 검증부 (Assertion) 5. 실행 결과 확인

Slide 59

Slide 59 text

Screenshot Testing ❏ UI의 시각적 모습이 예상대로 표시되는지 검증 ❏ 여러 해상도나 디바이스별 검증에 적합 from: Handshake Blog

Slide 60

Slide 60 text

Screenshot Testing 적절한 단위로 쪼개어진 컴포넌트별 테스트는 ❏ 한 번 작성된 Screenshot 테스트를 여러 테스트 케이스에서 활용 가능 ❏ 업데이트 범위를 최소화 ❏ 디자인 구현 정확도를 높임(디자인 시스템) from: Handshake Blog

Slide 61

Slide 61 text

상황에 따라 Preview만으로도 충분 ❏ 모든 시나리오에 UI 테스트 작성은 현실적으로 불가 ❏ UI의 레이아웃 및 스타일 검증은 Preview만으로도 충분할 수 있음 ❏ Stateless 컴포넌트로 구성하면 Precondition 설정 가능 ⬇Interactive Mode

Slide 62

Slide 62 text

Compose Preview Screenshot Testing from: Google I/O 2024 ❏ Preview 기반 Reference 이미지 생성 ❏ Screenshot Testing의 장점과 Preview의 장점을 결합 ❏ 아직 실험적 단계

Slide 63

Slide 63 text

Compose Preview Screenshot Testing

Slide 64

Slide 64 text

Compose UI 테스트 방법 및 도구 정리 ❏ Compose UI Testing ❏ Screenshot Testing ❏ Compose Preview

Slide 65

Slide 65 text

❏ Compose UI Testing ❏ Screenshot Testing ❏ Compose Preview Compose UI 테스트 방법 및 도구 정리 테스트 용도와 현실적인 리소스에 따라 선택이 달라진다. 어떤 방법을 선택하더라도 적절한 단위의 컴포넌트 분리와 테스트 가능한 컴포넌트 설계는 중요한 요소다.

Slide 66

Slide 66 text

1. 재사용 가능한 컴포넌트 2. 결합도를 낮추고 응집도를 높인 설계 3. 직관적인 API 디자인 4. 상태와 상태를 표시하는 UI 분리 -> 변경사항에 유연하게 대처 -> 테스트 가능한 컴포넌트

Slide 67

Slide 67 text

마무리

Slide 68

Slide 68 text

AOSP (Android Open Source Project) https://source.android.com AOSP는 안드로이드 운영 체제의 소스 코드를 관리하는 프로젝트로, 누구나 소스 코드를 열람하고 코드와 문서로 기여할 수 있다.

Slide 69

Slide 69 text

No content

Slide 70

Slide 70 text

No content

Slide 71

Slide 71 text

AOSP - Compose 컴포넌트 테스트 이러한 테스트 코드는 단순히 기능을 검증하는 것 뿐만 아니라, 해당 컴포넌트가 어떻게 사용되어야 하는지, 어떤 결과물이 기대되는지에 대한 실질적인 문서 역할을 하기도 한다. ❏ ButtonTest ❏ TextFieldTest ❏ LazyColumnTest ❏ SideEffectTests

Slide 72

Slide 72 text

참고자료 ❏ 안드로이드 공식 문서 - Compose ❏ React 공식 문서 - Thinking in react ❏ The Right Way to Test React Components - Stephen Scott ❏ 변경에 유연한 컴포넌트 - jbee

Slide 73

Slide 73 text

참고자료

Slide 74

Slide 74 text

참고자료 주제는 UI 컴포넌트 설계인데 참고자료 무슨 일?!!? 😳

Slide 75

Slide 75 text

엄청난 아키텍처 패턴이나 방법론을 고려하기 전에, 변경사항에 대처하기 쉽고 유연한 설계부터 시작하자 즉, 기본기를 되새기자

Slide 76

Slide 76 text

감사합니다 발표자료 Github Compose UI 컴포넌트 설계와 테스트