콘텐츠 대표 이미지 - 안드로이드 테스트 자동화: JUnit과 Espresso로 앱 품질 높이는 꿀팁 대방출! 🚀

안드로이드 테스트 자동화: JUnit과 Espresso로 앱 품질 높이는 꿀팁 대방출! 🚀

안녕, 개발자 친구! 🙌 오늘은 안드로이드 앱 개발할 때 정말 중요하지만 은근히 미루게 되는 그것! 바로 테스트 자동화에 대해 함께 알아볼 거야. JUnit과 Espresso라는 강력한 도구를 활용해서 앱의 품질을 확 끌어올리는 방법을 알려줄게. 이 글을 다 읽고 나면 테스트 코드 작성이 더 이상 부담스럽지 않을 거야! 오히려 "왜 진작 이렇게 안 했지?"라는 생각이 들걸? 😉

📑 목차

  1. 테스트 자동화가 필요한 이유
  2. JUnit으로 단위 테스트 마스터하기
  3. Espresso로 UI 테스트 자동화하기
  4. 테스트 자동화 실전 전략
  5. CI/CD 파이프라인에 테스트 통합하기
  6. 테스트 자동화의 모범 사례와 팁

1. 테스트 자동화가 필요한 이유 🤔

개발자로서 우리는 코드를 작성하고 앱을 만드는 걸 좋아하지만, 테스트는... 음, 좀 귀찮게 느껴질 때가 많지? 하지만 잠깐! 테스트 자동화가 왜 중요한지 한번 생각해보자.

1.1 수동 테스트의 한계

앱을 직접 클릭하고 스와이프하면서 테스트하는 건 초기에는 괜찮을 수 있어. 하지만 앱이 커지고 기능이 많아질수록 모든 기능을 매번 수동으로 테스트하는 건 거의 불가능해져. 게다가 사람이 하는 테스트는 항상 실수할 가능성이 있고, 같은 테스트를 반복하다 보면 집중력도 떨어지게 돼. 😓

1.2 자동화 테스트의 장점

자동화 테스트를 도입하면 다음과 같은 엄청난 이점이 있어:

  1. 시간 절약: 한 번 작성해두면 몇 초 만에 수백 개의 테스트 케이스를 실행할 수 있어.
  2. 일관성: 컴퓨터는 지치지 않고 항상 같은 방식으로 테스트를 실행해.
  3. 회귀 테스트: 새 기능을 추가하거나 코드를 수정할 때마다 기존 기능이 망가지지 않았는지 빠르게 확인할 수 있어.
  4. 문서화: 테스트 코드 자체가 앱의 동작 방식을 설명하는 살아있는 문서가 돼.
  5. 자신감: 코드 변경에 대한 두려움이 줄어들고, 리팩토링도 더 자신있게 할 수 있어.

1.3 안드로이드 테스트 피라미드

효과적인 테스트 전략을 세우려면 '테스트 피라미드'라는 개념을 이해하는 게 좋아:

안드로이드 테스트 피라미드 단위 테스트, 통합 테스트, UI 테스트의 단계와 특성을 명확한 색상과 텍스트로 표현한 인포그래픽 안드로이드 테스트 피라미드 JUnit + Espresso로 안정적인 품질 확보 수량 많음 적음 실행 속도 빠름 느림 UI 테스트 (Espresso) 적은 수 · 느린 실행 · 사용자 흐름 검증 통합 테스트 (Integration Tests) 모듈 간 상호작용 · 경계/계약 검증 단위 테스트 (JUnit) 많은 수 · 빠른 실행 · 로직 안정화 상위로 갈수록 시나리오 복잡도와 유지비용 증가 · 하위로 갈수록 피드백 속도 향상

피라미드의 각 층은 다음을 의미해:

  1. 단위 테스트 (하단): 가장 기본이 되는 테스트로, 개별 클래스나 메소드의 동작을 검증해. JUnit을 주로 사용하지.
  2. 통합 테스트 (중간): 여러 컴포넌트 간의 상호작용을 테스트해. 예를 들어 데이터베이스와 비즈니스 로직 간의 상호작용 같은 것.
  3. UI 테스트 (상단): 사용자 관점에서 앱의 동작을 테스트해. Espresso 같은 도구를 사용해서 실제 UI 요소와 상호작용하는 테스트를 작성해.

이상적인 테스트 전략은 단위 테스트를 가장 많이 작성하고, 그 다음으로 통합 테스트, 그리고 UI 테스트는 가장 적게 작성하는 거야. 이렇게 하면 테스트 실행 속도도 빠르고 유지보수도 쉬워져.

2. JUnit으로 단위 테스트 마스터하기 🧪

JUnit은 자바 생태계에서 가장 널리 사용되는 단위 테스트 프레임워크야. 안드로이드 스튜디오에 기본으로 통합되어 있어서 별도 설치 없이 바로 사용할 수 있어. 이제 JUnit을 활용한 단위 테스트 작성법을 알아보자!

2.1 JUnit 기본 설정

먼저 app/build.gradle 파일에 JUnit 의존성을 추가해야 해:

dependencies {
    // JUnit 4
    testImplementation 'junit:junit:4.13.2'
    
    // JUnit 5 (Jupiter)
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    
    // Mockito - 목 객체 생성 라이브러리
    testImplementation 'org.mockito:mockito-core:4.0.0'
}

여기서 testImplementation은 테스트 코드에만 사용되는 의존성을 의미해. 실제 앱에는 포함되지 않으니 앱 크기에 영향을 주지 않아!

2.2 첫 번째 단위 테스트 작성하기

간단한 계산기 클래스를 테스트하는 예제를 만들어 볼게:

먼저 테스트할 클래스:

// Calculator.java
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public double divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("Cannot divide by zero");
        }
        return (double) a / b;
    }
}

이제 이 계산기 클래스를 테스트하는 JUnit 테스트 클래스를 작성해보자:

// CalculatorTest.java
import org.junit.Test;
import static org.junit.Assert.*;

public class CalculatorTest {
    
    private final Calculator calculator = new Calculator();
    
    @Test
    public void testAdd() {
        assertEquals(5, calculator.add(2, 3));
        assertEquals(0, calculator.add(-5, 5));
        assertEquals(-8, calculator.add(-5, -3));
    }
    
    @Test
    public void testSubtract() {
        assertEquals(2, calculator.subtract(5, 3));
        assertEquals(-2, calculator.subtract(3, 5));
        assertEquals(0, calculator.subtract(5, 5));
    }
    
    @Test
    public void testMultiply() {
        assertEquals(15, calculator.multiply(3, 5));
        assertEquals(-15, calculator.multiply(-3, 5));
        assertEquals(0, calculator.multiply(0, 5));
    }
    
    @Test
    public void testDivide() {
        assertEquals(2.0, calculator.divide(10, 5), 0.0001);
        assertEquals(0.5, calculator.divide(1, 2), 0.0001);
    }
    
    @Test(expected = IllegalArgumentException.class)
    public void testDivideByZero() {
        calculator.divide(10, 0);
    }
}

2.3 JUnit 주요 어노테이션

JUnit에서 자주 사용되는 어노테이션들을 알아보자:

  1. @Test: 테스트 메소드임을 표시
  2. @Before: 각 테스트 메소드 실행 전에 실행되는 메소드
  3. @After: 각 테스트 메소드 실행 후에 실행되는 메소드
  4. @BeforeClass: 테스트 클래스 시작 전 한 번만 실행되는 정적 메소드
  5. @AfterClass: 테스트 클래스 종료 후 한 번만 실행되는 정적 메소드
  6. @Ignore: 테스트를 일시적으로 비활성화
  7. @RunWith: 테스트 실행기를 지정

이 어노테이션들을 활용한 예제를 보자:

import org.junit.*;
import static org.junit.Assert.*;

public class LifecycleTest {
    
    @BeforeClass
    public static void setUpClass() {
        System.out.println("테스트 클래스 시작 전 한 번만 실행");
    }
    
    @Before
    public void setUp() {
        System.out.println("각 테스트 메소드 실행 전마다 실행");
    }
    
    @Test
    public void testMethod1() {
        System.out.println("테스트 메소드 1 실행");
        assertTrue(true);
    }
    
    @Test
    public void testMethod2() {
        System.out.println("테스트 메소드 2 실행");
        assertFalse(false);
    }
    
    @Ignore("아직 구현 중인 테스트")
    @Test
    public void testMethodNotReady() {
        // 이 테스트는 실행되지 않음
    }
    
    @After
    public void tearDown() {
        System.out.println("각 테스트 메소드 실행 후마다 실행");
    }
    
    @AfterClass
    public static void tearDownClass() {
        System.out.println("테스트 클래스 종료 후 한 번만 실행");
    }
}

2.4 Mockito로 의존성 모킹하기

실제 개발에서는 테스트하려는 클래스가 다른 클래스에 의존하는 경우가 많아. 이럴 때 Mockito 같은 모킹 라이브러리를 사용하면 의존성을 가짜 객체로 대체할 수 있어. 이렇게 하면 테스트 대상 클래스만 격리해서 테스트할 수 있지.

예를 들어, 사용자 정보를 가져오는 서비스를 테스트한다고 가정해보자:

// UserService.java
public class UserService {
    private final UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    public User getUserById(String userId) {
        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException("User not found with id: " + userId);
        }
        return user;
    }
    
    public boolean isAdmin(String userId) {
        User user = getUserById(userId);
        return "ADMIN".equals(user.getRole());
    }
}

// UserRepository.java (인터페이스)
public interface UserRepository {
    User findById(String userId);
}

이제 Mockito를 사용해서 UserRepository를 모킹하는 테스트를 작성해보자:

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Test
    public void testGetUserById_UserExists() {
        // 모의 객체 설정
        User mockUser = new User("123", "테스트유저", "ADMIN");
        when(userRepository.findById("123")).thenReturn(mockUser);
        
        // 테스트 대상 객체 생성
        UserService userService = new UserService(userRepository);
        
        // 테스트 실행
        User result = userService.getUserById("123");
        
        // 검증
        assertEquals("123", result.getId());
        assertEquals("테스트유저", result.getName());
        
        // userRepository.findById가 정확히 한 번 호출되었는지 검증
        verify(userRepository, times(1)).findById("123");
    }
    
    @Test(expected = UserNotFoundException.class)
    public void testGetUserById_UserNotFound() {
        // 모의 객체 설정 - null 반환하도록
        when(userRepository.findById("456")).thenReturn(null);
        
        // 테스트 대상 객체 생성
        UserService userService = new UserService(userRepository);
        
        // 예외가 발생해야 함
        userService.getUserById("456");
    }
    
    @Test
    public void testIsAdmin_ReturnsTrueForAdmin() {
        // 모의 객체 설정
        User adminUser = new User("123", "관리자", "ADMIN");
        when(userRepository.findById("123")).thenReturn(adminUser);
        
        // 테스트 대상 객체 생성
        UserService userService = new UserService(userRepository);
        
        // 테스트 실행 및 검증
        assertTrue(userService.isAdmin("123"));
    }
    
    @Test
    public void testIsAdmin_ReturnsFalseForNonAdmin() {
        // 모의 객체 설정
        User regularUser = new User("789", "일반사용자", "USER");
        when(userRepository.findById("789")).thenReturn(regularUser);
        
        // 테스트 대상 객체 생성
        UserService userService = new UserService(userRepository);
        
        // 테스트 실행 및 검증
        assertFalse(userService.isAdmin("789"));
    }
}

Mockito를 사용하면 실제 데이터베이스나 네트워크 호출 없이도 다양한 시나리오를 테스트할 수 있어. 이게 단위 테스트의 핵심이지! 외부 의존성 없이 코드의 특정 부분만 격리해서 테스트하는 거야.

3. Espresso로 UI 테스트 자동화하기 📱

단위 테스트가 코드의 개별 부분을 테스트한다면, UI 테스트는 사용자 관점에서 앱이 제대로 동작하는지 확인해. 안드로이드에서는 Espresso라는 강력한 UI 테스트 프레임워크를 제공하고 있어.

3.1 Espresso 설정하기

Espresso를 사용하려면 app/build.gradle 파일에 다음 의존성을 추가해야 해:

dependencies {
    // Espresso 핵심 라이브러리
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    
    // 추가 Espresso 라이브러리 (선택적)
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
    
    // 안드로이드 테스트 러너
    androidTestImplementation 'androidx.test:runner:1.4.0'
    androidTestImplementation 'androidx.test:rules:1.4.0'
}

여기서 androidTestImplementation은 안드로이드 기기나 에뮬레이터에서 실행되는 테스트를 위한 의존성이야. 이 테스트들은 실제 안드로이드 환경에서 실행되기 때문에 UI 테스트에 적합해.

3.2 첫 번째 Espresso 테스트 작성하기

간단한 로그인 화면을 테스트하는 예제를 만들어볼게. 먼저 테스트할 액티비티가 있다고 가정해보자:

// LoginActivity.java
public class LoginActivity extends AppCompatActivity {
    
    private EditText usernameEditText;
    private EditText passwordEditText;
    private Button loginButton;
    private TextView errorTextView;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);
        
        usernameEditText = findViewById(R.id.username_edit_text);
        passwordEditText = findViewById(R.id.password_edit_text);
        loginButton = findViewById(R.id.login_button);
        errorTextView = findViewById(R.id.error_text_view);
        
        loginButton.setOnClickListener(v -> {
            String username = usernameEditText.getText().toString();
            String password = passwordEditText.getText().toString();
            
            if (username.isEmpty() || password.isEmpty()) {
                errorTextView.setVisibility(View.VISIBLE);
                errorTextView.setText("Username and password cannot be empty");
            } else if ("admin".equals(username) && "password123".equals(password)) {
                errorTextView.setVisibility(View.GONE);
                // 로그인 성공 - 메인 화면으로 이동
                startActivity(new Intent(this, MainActivity.class));
                finish();
            } else {
                errorTextView.setVisibility(View.VISIBLE);
                errorTextView.setText("Invalid username or password");
            }
        });
    }
}

이제 이 로그인 화면을 테스트하는 Espresso 테스트를 작성해보자:

// LoginActivityTest.java
@RunWith(AndroidJUnit4.class)
public class LoginActivityTest {
    
    @Rule
    public ActivityScenarioRule<LoginActivity> activityRule = 
            new ActivityScenarioRule<>(LoginActivity.class);
    
    @Test
    public void emptyUsernameAndPassword_ShowsError() {
        // 로그인 버튼 클릭
        onView(withId(R.id.login_button))
                .perform(click());
        
        // 에러 메시지가 표시되는지 확인
        onView(withId(R.id.error_text_view))
                .check(matches(isDisplayed()))
                .check(matches(withText("Username and password cannot be empty")));
    }
    
    @Test
    public void validCredentials_NavigatesToMainActivity() {
        // 사용자명과 비밀번호 입력
        onView(withId(R.id.username_edit_text))
                .perform(typeText("admin"), closeSoftKeyboard());
        
        onView(withId(R.id.password_edit_text))
                .perform(typeText("password123"), closeSoftKeyboard());
        
        // 로그인 버튼 클릭
        onView(withId(R.id.login_button))
                .perform(click());
        
        // MainActivity로 이동했는지 확인
        // 이 부분은 IntentsTestRule을 사용하거나 다른 방법으로 검증해야 함
        // 여기서는 간단히 에러 메시지가 보이지 않는지만 확인
        onView(withId(R.id.error_text_view))
                .check(matches(not(isDisplayed())));
    }
    
    @Test
    public void invalidCredentials_ShowsError() {
        // 잘못된 사용자명과 비밀번호 입력
        onView(withId(R.id.username_edit_text))
                .perform(typeText("wrong"), closeSoftKeyboard());
        
        onView(withId(R.id.password_edit_text))
                .perform(typeText("wrong123"), closeSoftKeyboard());
        
        // 로그인 버튼 클릭
        onView(withId(R.id.login_button))
                .perform(click());
        
        // 에러 메시지가 표시되는지 확인
        onView(withId(R.id.error_text_view))
                .check(matches(isDisplayed()))
                .check(matches(withText("Invalid username or password")));
    }
}

3.3 Espresso의 핵심 개념

Espresso 테스트를 작성할 때 알아야 할 세 가지 핵심 개념이 있어:

  1. ViewMatchers: 화면에서 뷰를 찾는 데 사용 (예: withId, withText)
  2. ViewActions: 뷰에 대한 사용자 동작을 시뮬레이션 (예: click, typeText)
  3. ViewAssertions: 뷰의 상태를 검증 (예: matches)

이 세 가지 요소를 조합해서 Espresso 테스트를 작성하는 기본 패턴은 다음과 같아:

onView(ViewMatcher)
    .perform(ViewAction)
    .check(ViewAssertion);

3.4 복잡한 UI 테스트 시나리오

실제 앱에서는 더 복잡한 UI 요소와 상호작용해야 할 때가 많아. 몇 가지 고급 Espresso 테스트 기법을 알아보자:

3.4.1 RecyclerView 테스트

// RecyclerView의 특정 위치에 있는 항목과 상호작용
@Test
public void clickOnRecyclerViewItem() {
    // RecyclerView의 5번째 항목 클릭
    onView(withId(R.id.recycler_view))
            .perform(RecyclerViewActions.actionOnItemAtPosition(4, click()));
    
    // 클릭 후 상세 화면에 올바른 제목이 표시되는지 확인
    onView(withId(R.id.detail_title))
            .check(matches(withText("Expected Item Title")));
}

// RecyclerView에서 특정 텍스트를 가진 항목 찾기
@Test
public void findItemWithText() {
    onView(withId(R.id.recycler_view))
            .perform(RecyclerViewActions.scrollTo(
                    hasDescendant(withText("Specific Item Text"))
            ))
            .perform(click());
}

3.4.2 커스텀 매처 만들기

// 커스텀 매처 정의
public static Matcher<View> withItemCount(final int count) {
    return new BoundedMatcher<View, RecyclerView>(RecyclerView.class) {
        @Override
        public void describeTo(Description description) {
            description.appendText("with item count: " + count);
        }

        @Override
        protected boolean matchesSafely(RecyclerView recyclerView) {
            return recyclerView.getAdapter().getItemCount() == count;
        }
    };
}

// 커스텀 매처 사용
@Test
public void recyclerViewHasCorrectItemCount() {
    onView(withId(R.id.recycler_view))
            .check(matches(withItemCount(10)));
}

3.4.3 인텐트 테스트

@Rule
public IntentsTestRule<MainActivity> intentsRule = 
        new IntentsTestRule<>(MainActivity.class);

@Test
public void clickingShareButton_OpensShareIntent() {
    // 공유 버튼 클릭
    onView(withId(R.id.share_button)).perform(click());
    
    // 인텐트가 올바른 액션으로 발생했는지 확인
    intended(allOf(
            hasAction(Intent.ACTION_SEND),
            hasType("text/plain"),
            hasExtra(Intent.EXTRA_TEXT, "Shared content")
    ));
}

Espresso는 실제 사용자처럼 앱과 상호작용하면서 UI를 테스트할 수 있게 해줘. 이런 테스트는 실행 속도가 단위 테스트보다 느리지만, 실제 사용자 시나리오를 검증하는 데 매우 유용해. 재능넷에서 개발자들이 앱 개발 프로젝트를 진행할 때 이런 UI 테스트 자동화 기술을 활용하면 품질 높은 앱을 더 효율적으로 만들 수 있을 거야! 🚀

4. 테스트 자동화 실전 전략 🎯

이제 JUnit과 Espresso의 기본을 알았으니, 실제 프로젝트에서 테스트 자동화를 효과적으로 적용하는 전략에 대해 알아보자.

4.1 테스트하기 좋은 코드 작성하기

테스트 자동화의 성공은 코드 자체가 얼마나 테스트하기 좋게 작성되었는지에 크게 달려 있어. 테스트하기 좋은 코드의 특징은 다음과 같아:

  1. 단일 책임 원칙(SRP): 각 클래스는 하나의 책임만 가져야 해. 이렇게 하면 테스트 범위가 명확해져.
  2. 의존성 주입(DI): 외부 의존성을 직접 생성하지 말고 주입받아. 이렇게 하면 테스트 시 모의 객체로 쉽게 대체할 수 있어.
  3. 인터페이스 활용: 구체적인 구현보다 인터페이스에 의존하면 테스트 시 다양한 구현체로 대체하기 쉬워져.
  4. 순수 함수 지향: 같은 입력에 항상 같은 출력을 반환하고 부작용이 없는 함수는 테스트하기 쉬워.
  5. 관심사 분리: UI 로직, 비즈니스 로직, 데이터 액세스 로직을 분리하면 각 부분을 독립적으로 테스트할 수 있어.

4.2 MVVM 아키텍처와 테스트

안드로이드에서 널리 사용되는 MVVM(Model-View-ViewModel) 아키텍처는 테스트 용이성을 크게 향상시켜. 각 컴포넌트의 역할과 테스트 전략을 살펴보자:

View Activity, Fragment XML 레이아웃 Espresso로 테스트 ViewModel UI 상태 관리 비즈니스 로직 JUnit으로 테스트 Model 데이터 소스 Repository JUnit + Mockito로 테스트 사용자 입력 UI 업데이트 데이터 요청 데이터 응답 MVVM 아키텍처와 테스트 전략
  1. Model: 데이터와 비즈니스 로직을 담당
    • 테스트 방법: JUnit으로 단위 테스트, 외부 의존성은 Mockito로 모킹
    • 테스트 대상: Repository, DataSource, UseCase 등
  2. ViewModel: UI 상태 관리 및 Model과 View 사이의 중재자
    • 테스트 방법: JUnit으로 단위 테스트, LiveData/Flow 테스트
    • 테스트 대상: 상태 변환 로직, 사용자 입력 처리, 비즈니스 로직 호출
  3. View: UI 요소와 사용자 상호작용
    • 테스트 방법: Espresso로 UI 테스트
    • 테스트 대상: 화면 렌더링, 사용자 입력 처리, 화면 전환

MVVM 아키텍처를 사용하면 ViewModel과 Model 계층은 안드로이드 프레임워크에 의존하지 않기 때문에 JUnit으로 빠르게 테스트할 수 있어. View 계층만 Espresso로 테스트하면 되지.

4.3 테스트 더블(Test Doubles) 이해하기

테스트 더블은 실제 객체 대신 테스트에서 사용하는 대체 객체를 말해. 각각의 용도를 알아보자:

  1. Dummy: 단순히 파라미터를 채우기 위한 객체로, 실제로는 사용되지 않아.
  2. Fake: 실제 구현을 단순화한 객체. 예: 실제 데이터베이스 대신 메모리 내 데이터베이스 사용.
  3. Stub: 미리 정의된 응답을 반환하는 객체. 테스트 중에 호출될 때 특정 값을 반환하도록 프로그래밍돼.
  4. Spy: 실제 객체를 감싸서 호출을 기록하는 객체. 실제 메소드를 호출하면서 추가 정보를 기록해.
  5. Mock: 예상되는 호출과 그에 대한 응답을 프로그래밍한 객체. 예상대로 호출되었는지 검증할 수 있어.

Mockito는 이러한 테스트 더블을 쉽게 만들 수 있게 해주는 라이브러리야. 예를 들어:

// Mock 객체 생성
UserRepository mockRepository = mock(UserRepository.class);

// Stub 동작 정의
when(mockRepository.findById("123")).thenReturn(new User("123", "Test User"));

// 테스트 대상 객체에 주입
UserService userService = new UserService(mockRepository);

// 테스트 실행
User user = userService.getUserById("123");

// 검증
assertEquals("Test User", user.getName());

// Mock 객체가 예상대로 호출되었는지 검증 (Verify)
verify(mockRepository).findById("123");

4.4 비동기 코드 테스트하기

안드로이드 앱에서는 네트워크 요청, 데이터베이스 액세스 등 비동기 작업이 많아. 이런 비동기 코드를 테스트하는 방법을 알아보자:

4.4.1 LiveData 테스트

@Test
public void fetchUser_emitsUserData() {
    // 준비
    MutableLiveData<User> liveData = new MutableLiveData<>();
    when(userRepository.getUserById("123")).thenReturn(liveData);
    
    UserViewModel viewModel = new UserViewModel(userRepository);
    
    // Observer 설정
    Observer<User> observer = mock(Observer.class);
    viewModel.getUserData().observeForever(observer);
    
    // 액션
    viewModel.fetchUser("123");
    User user = new User("123", "Test User");
    liveData.setValue(user);  // 비동기 응답 시뮬레이션
    
    // 검증
    verify(observer).onChanged(user);
}

4.4.2 코루틴 테스트

@Test
fun fetchUserCoroutine_returnsUser() = runBlockingTest {
    // 준비
    val user = User("123", "Test User")
    coEvery { userRepository.getUserById("123") } returns user
    
    val viewModel = UserViewModel(userRepository)
    
    // 액션
    viewModel.fetchUserCoroutine("123")
    
    // 검증
    assertEquals(user, viewModel.user.value)
}

4.4.3 RxJava 테스트

@Test
public void fetchUserRx_emitsUser() {
    // 준비
    User user = new User("123", "Test User");
    when(userRepository.getUserByIdRx("123")).thenReturn(Single.just(user));
    
    UserViewModel viewModel = new UserViewModel(userRepository);
    
    // 액션 & 검증
    viewModel.fetchUserRx("123")
            .test()
            .assertValue(user)
            .assertComplete();
}

비동기 코드를 테스트할 때는 테스트 실행 시간을 제어하고 비동기 이벤트를 동기적으로 처리할 수 있는 도구를 활용하는 게 중요해. 이렇게 하면 테스트가 더 안정적이고 예측 가능해져.

5. CI/CD 파이프라인에 테스트 통합하기 🔄

테스트 자동화의 진정한 가치는 지속적 통합(CI)과 지속적 배포(CD) 파이프라인에 통합될 때 발휘돼. 이렇게 하면 코드 변경이 있을 때마다 자동으로 테스트가 실행되고, 문제가 있으면 즉시 알 수 있어.

5.1 GitHub Actions로 CI 설정하기

GitHub Actions는 GitHub에서 제공하는 CI/CD 도구로, 안드로이드 프로젝트에 쉽게 적용할 수 있어. 다음은 기본적인 워크플로우 설정 예시야:

name: Android CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    
    - name: Set up JDK 11
      uses: actions/setup-java@v2
      with:
        java-version: '11'
        distribution: 'adopt'
        
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
      
    - name: Build with Gradle
      run: ./gradlew build
      
    - name: Run Unit Tests
      run: ./gradlew test
      
    - name: Run Instrumented Tests
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 29
        script: ./gradlew connectedAndroidTest
        
    - name: Upload Test Reports
      uses: actions/upload-artifact@v2
      with:
        name: test-reports
        path: app/build/reports/

이 워크플로우는 다음과 같은 단계로 실행돼:

  1. 코드 체크아웃
  2. JDK 설정
  3. Gradle 빌드 실행
  4. 단위 테스트 실행
  5. 에뮬레이터에서 UI 테스트 실행
  6. 테스트 보고서 업로드

5.2 테스트 보고서 활용하기

CI 시스템에서 생성된 테스트 보고서를 활용하면 프로젝트의 품질 상태를 한눈에 파악할 수 있어. JaCoCo 같은 도구를 사용하면 코드 커버리지 보고서도 생성할 수 있지.

// app/build.gradle에 JaCoCo 설정 추가
apply plugin: 'jacoco'

jacoco {
    toolVersion = "0.8.7"
}

tasks.withType(Test) {
    jacoco.includeNoLocationClasses = true
    jacoco.excludes = ['jdk.internal.*']
}

task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) {
    reports {
        xml.enabled = true
        html.enabled = true
    }

    def fileFilter = [
            '**/R.class',
            '**/R$*.class',
            '**/BuildConfig.*',
            '**/Manifest*.*',
            '**/*Test*.*',
            'android/**/*.*'
    ]
    def debugTree = fileTree(dir: "${buildDir}/intermediates/javac/debug", excludes: fileFilter)
    def mainSrc = "${project.projectDir}/src/main/java"

    sourceDirectories.from = files([mainSrc])
    classDirectories.from = files([debugTree])
    executionData.from = files("${buildDir}/jacoco/testDebugUnitTest.exec")
}

GitHub Actions 워크플로우에 이 태스크를 추가하면 코드 커버리지 보고서를 자동으로 생성할 수 있어:

- name: Generate Code Coverage Report
  run: ./gradlew jacocoTestReport
  
- name: Upload Coverage Report
  uses: actions/upload-artifact@v2
  with:
    name: coverage-report
    path: app/build/reports/jacoco/

5.3 테스트 실패 시 대응 전략

CI 파이프라인에서 테스트가 실패하면 어떻게 대응해야 할까? 몇 가지 전략을 알아보자:

  1. 즉시 수정: 테스트 실패는 최우선으로 해결해야 해. "깨진 창문 이론"처럼, 실패한 테스트를 방치하면 더 많은 문제가 생길 수 있어.
  2. 원인 분석: 테스트가 실패한 원인이 실제 버그인지, 테스트 코드의 문제인지, 환경 문제인지 분석해.
  3. 회귀 테스트: 수정 후에는 모든 테스트를 다시 실행해서 다른 부분에 영향을 주지 않았는지 확인해.
  4. 테스트 개선: 불안정한 테스트(flaky tests)가 있다면 더 안정적으로 개선해.

5.4 테스트 자동화와 배포 연계

테스트 자동화를 CD(지속적 배포) 파이프라인과 연계하면 더욱 강력해져. 예를 들어:

  1. 자동 배포: 모든 테스트가 통과하면 자동으로 테스트 서버나 베타 채널에 배포
  2. 단계적 롤아웃: 테스트 통과 → 내부 테스터 배포 → 베타 테스터 배포 → 프로덕션 배포
  3. A/B 테스트: 새 기능에 대한 테스트가 통과하면 일부 사용자에게만 배포하여 실제 환경에서 테스트

재능넷에서도 이런 CI/CD 파이프라인을 활용하면 개발 생산성과 코드 품질을 크게 향상시킬 수 있을 거야. 특히 여러 개발자가 협업하는 프로젝트에서는 테스트 자동화와 CI/CD 통합이 거의 필수적이지! 🚀

6. 테스트 자동화의 모범 사례와 팁 💡

지금까지 안드로이드 테스트 자동화의 기술적인 측면을 살펴봤어. 이제 실제 프로젝트에서 테스트 자동화를 성공적으로 적용하기 위한 모범 사례와 팁을 알아보자!

6.1 효과적인 테스트 작성 원칙

좋은 테스트는 다음과 같은 특성을 가져:

  1. Fast (빠름): 테스트는 빠르게 실행되어야 해. 느린 테스트는 개발 흐름을 방해하고 자주 실행되지 않게 돼.
  2. Isolated/Independent (독립적): 각 테스트는 다른 테스트에 의존하지 않고 독립적으로 실행될 수 있어야 해.
  3. Repeatable (반복 가능): 테스트는 환경에 상관없이 항상 같은 결과를 내야 해.
  4. Self-validating (자체 검증): 테스트는 자동으로 결과를 검증하고 통과/실패를 명확히 알려줘야 해.
  5. Timely (적시에): 테스트는 프로덕션 코드 작성 전이나 직후에 작성되어야 해.

이 원칙들의 첫 글자를 모으면 "FIRST"가 돼. 테스트를 작성할 때 이 FIRST 원칙을 기억하면 좋은 테스트를 작성하는 데 도움이 될 거야.

6.2 테스트 코드 구조화: AAA 패턴

테스트 코드를 구조화하는 좋은 방법 중 하나는 AAA(Arrange-Act-Assert) 패턴을 사용하는 거야:

@Test
public void withdraw_sufficientFunds_reducesBalance() {
    // Arrange (준비): 테스트 환경 설정
    Account account = new Account(1000);
    
    // Act (실행): 테스트할 동작 수행
    boolean result = account.withdraw(600);
    
    // Assert (검증): 예상 결과 확인
    assertTrue(result);
    assertEquals(400, account.getBalance());
}

이 패턴을 사용하면 테스트 코드가 더 읽기 쉽고 이해하기 쉬워져. 각 섹션을 주석으로 구분하면 더 명확해지지.

6.3 테스트 가능한 아키텍처 설계

앱을 설계할 때부터 테스트 가능성을 고려하면 나중에 테스트 작성이 훨씬 쉬워져. 몇 가지 핵심 원칙을 살펴보자:

  1. 관심사 분리: UI, 비즈니스 로직, 데이터 액세스를 명확히 분리해.
  2. 의존성 주입: 클래스가 의존성을 직접 생성하지 말고 외부에서 주입받도록 해.
  3. 인터페이스 활용: 구체적인 구현보다 인터페이스에 의존해.
  4. 단방향 데이터 흐름: 데이터가 한 방향으로만 흐르도록 설계해.
  5. 상태 관리 단순화: 상태 변경을 예측 가능하게 만들어.

안드로이드에서는 MVVM, MVP, MVI 같은 아키텍처 패턴을 활용하면 테스트 가능성이 크게 향상돼.

6.4 테스트 우선순위 정하기

모든 코드를 100% 테스트하는 것은 현실적으로 어려울 수 있어. 따라서 테스트 우선순위를 정하는 것이 중요해:

  1. 핵심 비즈니스 로직: 앱의 핵심 기능과 비즈니스 로직은 반드시 테스트해.
  2. 복잡한 알고리즘: 복잡한 계산이나 알고리즘은 다양한 입력에 대해 테스트해.
  3. 자주 변경되는 코드: 자주 변경되는 코드는 회귀 테스트가 중요해.
  4. 버그가 발생했던 부분: 과거에 버그가 발생했던 부분은 재발 방지를 위해 철저히 테스트해.
  5. 사용자 중요 경로: 사용자가 자주 사용하는 핵심 경로는 UI 테스트로 커버해.

6.5 실제 프로젝트에서의 테스트 자동화 도입 전략

기존 프로젝트에 테스트 자동화를 도입하는 것은 쉽지 않을 수 있어. 다음과 같은 단계적 접근이 효과적이야:

  1. 작게 시작하기: 모든 코드를 한번에 테스트하려 하지 말고, 중요한 부분부터 시작해.
  2. 새 코드에 테스트 적용: 새로 작성하는 코드에는 반드시 테스트를 함께 작성해.
  3. 버그 수정 시 테스트 추가: 버그를 수정할 때마다 해당 버그를 재현하는 테스트를 먼저 작성해.
  4. 리팩토링 전 테스트 작성: 코드를 리팩토링하기 전에 현재 동작을 검증하는 테스트를 작성해.
  5. 점진적으로 커버리지 높이기: 시간이 지남에 따라 점진적으로 테스트 커버리지를 높여가.

6.6 테스트 자동화의 함정과 주의사항

테스트 자동화는 강력하지만, 몇 가지 주의해야 할 점이 있어:

  1. 불안정한 테스트(Flaky Tests): 같은 코드에 대해 때때로 실패하는 테스트는 신뢰성을 떨어뜨려. 특히 UI 테스트에서 자주 발생해.
  2. 과도한 모킹: 너무 많은 것을 모킹하면 실제 동작과 달라질 수 있어. 통합 테스트도 적절히 포함시켜.
  3. 구현 세부사항 테스트: 내부 구현보다는 공개 API와 동작을 테스트해. 구현 세부사항을 테스트하면 리팩토링이 어려워져.
  4. 테스트 유지보수 부담: 테스트 코드도 유지보수가 필요해. 테스트 코드의 품질과 가독성에도 신경 써야 해.
  5. 100% 커버리지 집착: 100% 테스트 커버리지가 목표가 되면 의미 없는 테스트를 작성하게 될 수 있어. 중요한 부분에 집중해.

6.7 테스트 자동화 문화 조성하기

테스트 자동화는 기술적인 문제만이 아니라 팀 문화의 문제이기도 해. 효과적인 테스트 문화를 조성하기 위한 팁:

  1. 리더십 지원: 테스트 작성에 시간을 투자할 수 있도록 리더십의 지원을 얻어.
  2. 지식 공유: 테스트 작성 방법과 모범 사례를 팀 내에서 공유해.
  3. 코드 리뷰에 포함: 코드 리뷰 시 테스트 코드도 함께 검토해.
  4. 테스트 작성 시간 인정: 테스트 작성에 필요한 시간을 일정에 포함시켜.
  5. 성공 사례 공유: 테스트 자동화로 인해 방지된 버그나 개선된 사항을 공유해.

재능넷에서 개발자들이 프로젝트를 진행할 때 이런 테스트 자동화 문화를 함께 조성한다면, 더 높은 품질의 앱을 더 효율적으로 개발할 수 있을 거야. 테스트 자동화는 단기적으로는 시간이 더 들지만, 장기적으로는 엄청난 시간과 비용을 절약해주는 투자라고 볼 수 있어! 🚀

마치며: 테스트 자동화로 앱 품질 높이기 🏆

지금까지 안드로이드 테스트 자동화에 대해 JUnit과 Espresso를 중심으로 알아봤어. 테스트 자동화는 단순히 버그를 찾는 도구가 아니라, 더 나은 설계를 유도하고 개발 속도를 높이며 자신감 있게 코드를 변경할 수 있게 해주는 강력한 개발 방법론이야.

처음에는 테스트 작성이 시간이 더 걸리고 번거롭게 느껴질 수 있어. 하지만 시간이 지날수록 테스트의 가치는 더욱 분명해질 거야. 특히 프로젝트가 커지고 복잡해질수록, 또는 여러 개발자가 함께 작업할수록 테스트 자동화의 중요성은 더욱 커져.

재능넷에서 개발 프로젝트를 의뢰하거나 수행할 때, 테스트 자동화를 도입하면 장기적으로 더 안정적이고 유지보수하기 쉬운 앱을 만들 수 있을 거야. 물론 모든 코드를 100% 테스트할 필요는 없어. 중요한 비즈니스 로직과 자주 변경되는 부분에 집중해서 테스트를 작성하는 것이 효율적이야.

마지막으로, 테스트 자동화는 단순한 기술적 도구가 아니라 개발 문화의 일부라는 점을 기억해. 팀 전체가 테스트의 가치를 이해하고 실천할 때 그 효과는 극대화돼. 테스트 자동화를 통해 더 나은 개발자가 되고, 더 좋은 앱을 만들어보자! 💪

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.