JIGGAG

유지보수를 위해 문서 보다 테스트 코드를 작성하자

2021년 9월 18일

유지보수를 위한 테스트 코드

문서 보다 테스트

문서화는 유지보수에 도움을 주는 요소이다.

이 메서드가 어떤 역할을 하는지 설명이 되어있다면 도메인 지식이나 프로그래밍 언어, 알고리즘 등 얽혀있는 복잡한 코드를 전부 이해하지 않아도 흐름을 이해할 수 있기 때문이다.

그러나 결국 유지보수하는 것은 코드이다. 복잡한 코드를 설명하기 위해 복잡한 문서가 필요하다면 코드가 변경될 때 마다 복잡한 문서를 풀어서 다시 작성하는 일이 반복될 것이다.

이것은 과연 유지보수를 위한 것일까? 오히려 유지보수를 위해 코드 + 문서 작업이 추가되는 것은 아닐까?

복잡하지 않은 코드, 단순하면서 의미가 명확한 코드는 문서가 추가적으로 필요하지 않다. 그러나 메서드, 프로퍼티 네이밍이 명확하지 않다면 (나를 이야기하는 것 같다) 다른 사람에게 이것은 어떤 의미이다라는 것을 전달해주는 문서가 필요해진다. 결국 설계가 제대로 되지 않았기 때문에 문서화가 불필요하게 작성되고 있다.

한편으로는 복잡한 알고리즘을 요구하는 코드를 작성해야할때 네이밍을 a, b, value... 사용하고 문서를 남기는게 나을까? 생각이 들기도 한다. 그러나 저런 명확하지 않은 네이밍은 결국 작성하고 있는 자신에게도 역할을 헷갈리게 만들고 같은 역할, 의미를 갖는 것이 이미 존재하는데 다시 만들어서 쓰는 악순환을 발견할 수 있다...

최상의 선택이 문서화 할 필요없이 코드 자체가 깔끔한 것이다.

그럼 깔끔한 코드는 무엇일까? 이해하기 쉬운 코드 + 유지보수가 쉬운 코드 + 의미가 명확한 코드 모두 맞는 것 같다. 그리고 오류를 찾기 쉬운 코드라고 생각한다.

모든 것이 매번 새로 작성되는 것이 아니라 유지보수를 더 많이 하게 되고 지금 객체지향의 목적도 유지보수를 위한 것이다. 이렇게 수많은 유지보수를 하다보면 오류가 분명 있는데 찾기 어려운 코드가 있다. 예를 들면 이전에 스터디했던 내용 중 가변 객체 를 사용한 경우 숨어있어서 찾기 어렵다...

오류를 찾기 쉬우려면 어떻게 해야할까? 무언가 원래 의도와 다르게 동작하고 있음을 빨리 캐치해야하는데, 이를 가장 쉽게 도와주는 것이 바로 테스트 코드 이다.

가장 작은 단위로 테스트를 하는 의미로 단위테스트를 작성해서 클래스, 메서드, 함수 단위를 테스트하게 된다.

테스트 코드로 그 함수의 역할, 기댓값 등을 미리 확인할 수 있기 때문에 복잡한 문서화보다 오히려 동작하는 코드를 설명하는 가장 좋은 문서가 테스트 코드이다.

다만 테스트 코드를 작성하는 것이 어려운 코드가 있을 수 있다. 그것이 바로 복잡한 깔끔하지 못한 코드이다ㅠㅠ


Mock 보다 Fake

클래스, 함수 테스트 코드를 작성할때 Mock을 사용하고는 한다. 테스트하려고 하는 클래스, 함수가 필요로하는 의존 객체를 모킹을 통해 전달하는 것이다.

class TestClass {
	private other: OtherClass;

	constructor(other: OtherClass) {
		this.other = other;
	}

  print() {
    return this.other.name;
  }
}

const mockOther = new OtherClass();

TestClassprint를 테스트하려고 하는데 OtherClass가 필요하다. new TestClass(mockOther)처럼 Mock을 만들어서 OtherClass를 대신하게 한다.

그러나 OtherClass가 또 다른 객체에 의존하고 있다면? 모의 객체를 만들기 위해 의존 객체를 만들어야 하고 그 의존 객체를 만들기 위해 의존의 의존 객체를 만들어야하는 연쇄작업 이어진다. (배보다 배꼽이 커진다)

... // 의존의 의존의 의존
const mockAnother = new AnotherClass(...);
const mockOther = new OtherClass(mockAnother);

그렇기 때문에 모의 객체보다는 페이크 객체를 사용하는 것을 추천한다. 모의 객체와는 다르게 그 객체가 반환하는 것을 Fake로 만들어둔 객체이다. 이렇게 하면 테스트를 더 짧게 만들 수 있기 때문에 유지보수성이 향상된다.

const fakeOther: OtherClass = {
	name: 'fake',
};

new TestClass(mockOther)처럼 모의 객체를 만들어서 전달하는 것이 아닌 new TestClass(fakeOther) 필요한 객체의 페이크 객체를 바로 전달하여 사용할 수 있게 되었다. (이게 내용이 헷갈리기 시작한다😱 Fake랑 Stub의 차이가 명확하게 보이지 않는다... Fake, Mock, Stub을 구분해서 만들어줄 수 있어야하겠다!)

모킹을 이용하면 단위테스트가 쉽게 깨질 수 있다! 실제로 테스트하려고 하는 대상이 변경된 것이 아니라면 단위테스트가 실패하면 안된다. TestClass를 테스트하려고 했는데 OtherClass의 무언가가 변경되었다고 TestClass의 테스트가 실패하면 안되는 것이다.

하지만 모킹은 TestClassOtherClass를 결합한 형태이기 때문에 단위테스트가 실패할 수 밖에 없다.

// 예를 들어 아래와 같은 OtherClass의 mock을 이용해 TestClass의 print를 테스트하고 있었다
class OtherClass {
	private name: string;

	constructor(name: string) {
		this.name = name;
	}
}

const mockOther = new OtherClass("mock");
expect(new TestClass(mockOther).print()).toBe("mock");

// 이때 OtherClass name을 아래처럼 변경한 경우 mock이 변경되면서 테스트를 실패하게 된다
class OtherClass {
	private name: string;

	constructor(name: string) {
		this.name = `[Other]${name}`;
	}
}

의존하고 있는 모의 객체가 예전과 다르게 상태가 변경되었거나 생성되지 않았을 수 있기 때문이다. 모킹은 클래스 구현과 관련된 내부 로직을 테스트 코드와 강하게 결합시켜서 오히려 유지보수, 리팩터링을 어렵게 만든다.

모킹 대신 페이크를 이용해 약속된 결과값을 가져와서 테스트 했다면 정말 단순하게 동작한다.

// 위의 예씨처럼 OtherClass의 생성자에서 name이 변경되더라도 fake 객체에서는 테스트가 깨지지 않는다
const fakeOther: OtherClass = {
	name: 'fake',
};
expect(new TestClass(fakeOther).print()).toBe("fake");

의존 대상이였던 OtherClass의 생성자가 변경되었더라고 fake 객체는 여전히 OtherClass를 반환하고 있을뿐이다. 생성자 인자를 하나를 사용했거나 두개를 사용하는 것은 OtherClass 내부 로직에서 처리할 일이고 이를 호출하는 곳에서는 OtherClass만 있으면 된다.

테스트가 코드의 내부 로직 구현에 관여를 하게 되면 오히려 테스트가 복잡해지고 유지보수가 어려워질뿐이다!


Mock, Fake, Stub

Mock Object는 모의 객체를 만들어서 정말 객체 그 자체를 반환한다. 따라서 객체 의존성을 해결해주어야한다.

Fake Object는 객체의 Mock보다는 간소화?된 특정 몇몇 상태의 객체를 반환하는 페이크이다.

Test Stub은 특정 상태를 하드코딩한 객체 반환한다. 특정 상태로 고정되어 있기 때문에 액션에 따른 테스트가 어렵다.

여러가지 조건에 따라 액션이 제한되고 반환되는 결과값을 테스트하기 위해 각각 조건에 따른 결과값을 하드코딩으로 준비해두고 액션을 주는 테스트를 작성했었다. (수많은 하드코딩 + 중복된 코드의 화려한 결과물로 2000줄이 되었다... 테스트 코드도 리팩터링이 필요하다는 것을 이때 깨달았다) 당시에는 이게 Mock라고 생각하고 사용했는데 지금 다시보니 Stub인가!

참고: Mock, Fake, Stub


짧은 인터페이스

클래스 하나의 응집도를 높이려면 클래스가 작게 유지되어야하고 클래스를 작게 만들기 위해서는 인터페이스도 작아야한다. 하나의 클래스에서는 여러개의 인터페이스를 구현할 수 있기 때문에 인터페이스는 정말 작아야한다!

하나의 인터페이스에 너무 많은 역할을 담고 있게 하면 그걸 구현하는 클래스에서는 응집도가 떨어질 수 밖에 없고 단일 책임 원칙에 어긋나게 된다. 각각 독립된 함수는 독립된 인터페이스로 구현하도록 정의되어야한다.

하지만 매우 밀접하게 연관된 인터페이스를 각각 독립된 상태로 만들기에는 오히려 너무 분산되는 느낌 아닐까?

그래서 스마트 클래스(공유하는 인터페이스라고 생각한다)를 추가해서 해결할 수 있다.

class Smart {
  private origin: Exchange;
  constructor(origin: Exchange) {
    this.origin = origin;
  }
	toUsd() {
    return this.origin.rate("usd");
  }
  toEur() {
    return this.origin.rate("eur");
  }
}

interface Exchange {
	rate(value: string): string;
  smart(origin: Exchange): Smart;
}

class Exchange {
  rate(value: string) {
    return value;
  }
  smart() {
    return new Smart(this);
  }
}

const smart = new Exchange().smart();
console.log(smart.toUsd()); // usd
console.log(smart.toEur()); // eur

공통된 로직을 하나의 스마트 클래스로 분리하는 것이다. Exchange 안에 Smart클래스가 들어가 있는데 이는 공통된 클래스를 인터페이스와 함께 제공하기 위한 것이다. 서로 다른 클래스에서 동일한 기능을 제공하고자 하는데, 공통된 로직을 계속 다시 구현하지 않도록 도와준다. (HOC 같은 느낌?)

스마트 클래스에 계속 추가하게 된다면 이 클래스 자체가 커지게 되지만 인터페이스의 응집도는 높은 상태로 작게 유지할 수 있고 공통적으로 사용하는 것은 스마트 클래스에서 구현해둔 로직을 가져와서 사용할 수 있게 되었다.

공통된 기능을 스마트 클래스로 추출하여 코드 중복을 피할 수 있고 인터페이스를 작게 유지할 수 있다