인터페이스 제공자 ↔ 인터페이스 사용자
다양한 인터페이스로 수많은 기능을 제공하지만 그만큼 위험도 크다.
ex) java.util.Map (Interface Map - JDK 11 Api Docs.)
넘기는 쪽에서는 아무도 Map 내용을 삭제하지 않을지도 모르지만 Map 사용자라면 누구나 Map 내용을 지울 권한이 있다. (Map.clear())
Map에 특정 객체 유형만 저장하기로 결정했다고 하지만 Map은 객체 유형을 제한하지 않기 때문에 마음만 먹으면 사용자는 어떤 객체 유형도 추가할 수 있음
ex) 맵의 생성과 객체 조회
Map sensors = new HashMap();
Sensor s = (Sensor) sensors.get("sensorId");
Map이 반환하는 Object를 올바른 유형으로 변환할 책임은 Map을 사용하는 client에 있다.
코드는 동작하지만 깨끗한 코드라 보긴 어려움. 또한 의도도 분명히 드러나지 않음.
Generics를 사용하여 Map에 담긴 내용에 대한 가독성을 높힐 수 있음. (Map<String, Sensor>
)하지만 사용자가 필요하지 않은 기능까지 제공한다는 문제는 해결하지 못한다.
프로그램에서 Map 인스턴스를 여기저기로 넘긴다면 Map 인터페이스가 변할 경우 수정할 코드가 상당히 많아진다.
Map을 Sensors 클래스 안으로 숨겨서 좀 더 깔끔하게 사용할 수 있음
public class Sensors {
private Map sensors = new HashMap();
public Sensor getById(String id) {
return (Sensor) sensors.get(id);
}
...
}
Sensors
안으로 숨겨 class 안에서 객체 유형을 관리하고 변환한다. ⇒ Map 인터페이스가 변하더라도 나머지 프로그램에는 영향을 미치지 않는다.자신이 관리하고, 입력하고 구현하는 객체, 클래스, 메서드 바깥으로 결과값을 내보낼 때는 별 신경쓰지 않고 내보낼 수 있는 객체면 좋겠지만... 웬만하면 다른 사용자가 쓰기 편한 객체르 내보내는 것이 좋은 것 같습니다.
Map<String, Object> 와 같은 return 값을 대할 때 드는 생각들을 주절주절해보자면
- Map의 유효한 key는 무엇일까? -> put()을 찾아봐야겠다. ㅠ
- 발견한 key로 조회할 수 있는 데이터타입은 무엇일까? 어떻게 cast 해야 할까?
- 조회한 데이터가 객체 클래스라면 어떤 역할(method)를 요청할 수 있을까?
- 조회한 데이터가 null 일 가능성이 있을까?
Logback, Log4j2, Log4jdbc-log4j2, log4sql 을 경험해봤다고는 이야기 할 수 있겠지만 잘 안다라고는 자신있게 말하긴 어려울 것 같습니다. 추후에 내용을 정리해보고 싶습니다.
화면에 "hello"를 출력하는 테스트 케이스
@Test
public void testLogCreate() {
Logger logger = Logger.getLogger("MyLogger");
logger.info("hello");
}
테스트 케이스가 돌아가지 않음 : Appender라는 뭔가가 필요하다는 오류 발생
ConsoleAppender 생성한 테스트
@Test
public void testLogCreate() {
Logger logger = Logger.getLogger("MyLogger");
ConsoleAppender appender = new ConsoleAppender();
logger.addAppender(appender);
logger.info("hello");
}
출력 스트림 추가한 테스트
@Test
public void testLogCreate() {
Logger logger = Logger.getLogger("MyLogger");
logger.removeAllAppenders();
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n"),
ConsoleAppender.SYSTEM_OUT));
logger.info("hello");
}
log4j로 단위 테스트 케이스 표현
public class LogTest {
private Logger logger;
@Before
public void initialize() {
logger = Logger.getLogger("logger");
logger.removeAllAppenders();
Logger.getRootLogger().removeAllAppenders();
}
@Test
public void basicLogger() {
BasicConfigurator.configure();
logger.info("basicLogger");
}
@Test
public void addAppenderWithStream() {
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n"),
ConsoleAppender.SYSTEM_OUT));
logger.info("addAppenderWithStream");
}
@Test
public void addAppenderWithoutStream() {
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n")));
logger.info("addAppenderWithoutStream");
}
낡은 버전을 오랫동안 사용하려는 유혹은 정말로 뿌리치기 힘든 것 같습니다. 변화 보다는 안정을 추구하는 조직이더라도 변경에는 대비해야 나중에 큰 문제에 당면하지 않을 것이라 생각합니다.
경계와 관련한 또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계이다.
시스템과 다른 시스템간의 경계 또는 지식이 경계를 너머 미치지 못하는 코드 영역도 있음
ex) 송신기 모듈에 원하는 기능 =>
지정한 주파수를 이용해 이 스트림에서 들어오는 자료를 아날로그 신호로 전송하라
디자인 패턴에 대해서는 좀 더 공부가 필요하다고 느낍니다. 그럼에도 최대한 정리해보자면
- interface를 통하면 역할을 수행하는 class를 갈아끼우기 편하다.
- Adapter 패턴을 통해 역할을 외부에서 의존하여 구성(field 값으로 설정) 하면 요구되는 타입이나 객체가 다르더라도 원하는 역할을 수행시킬 수 있다.
- 요구되는 역할을 정의해두었기 때문에 Mock 객체로 생성하기 수월할 것 같다.
외부 코드에 휘둘리지 않는다는 것을 외부 의존성에 대해서는 의도한 대로 동작하는 것을 확인하는 정도만 신경써도 괜찮다는 의미로 이해했습니다. 내부 동작이 어떤지는 블랙박스마냥 여기고, 반환하는 데이터를 어떻게 가공할 것인지에 대해 로직 구현에 집중하라는 의미로 와닿았습니다.
원하는 interface를 생성해두는 방식은 차후에 작성할 기회가 생기면 도전해보고 싶습니다. 필요한 기능을 찾아 API를 뒤적이는 것이 아니라 필요한, 그리고 기대한 기능만 가져다 쓰는 관점이 흥미롭게 생각되었습니다.
DSL
이라 유식한 척 말을 꺼낼 순 있겠지만 무엇인지 설명하라하면 잘 설명할 수 없는 관계로... 나중에 공부해둘 키워드로 선정!
방대한 코드는 관리하기 정말 어렵습니다. 특히 구현된 legacy code에 테스트를 작성하려고 시도해보면 단위로 빼는게 정말 어렵기 때문입니다.
⇒ 테스트 코드는 실제 코드 못지 않게 중요하다. (사고와 설계와 주의가 필요하다.)
"변경을 주저하게 됨" <- 코드를 작성하는데 이 생각이 든다면 코드 변경을 쉽게 시도할 수 없게 되는 것 같습니다. 제 자신 뿐 아니라 다른 팀원에게도 권하기 어렵기도 하구요.
커버리지가 높을수록 정말로 공포가 줄어드는지는 잘 모르겠지만 커버리지가 높다고 안정적인 코드를 뜻하는 것은 아니라고 생각합니다.
깨끗한 테스트 코드 = 높은 가독성!
가독성을 높이려면
개선이 필요한 테스트 코드의 예 - FitNess.SerializedPageResponderTest.java
위 테스트와 동일한 테스트를 수행하는 리팩토링된 테스트
테스트 API 코드에 적용하는 표준은 실제 코드에 적용하는 표준과 확실히 다르다.
이중 표준이 쓰인 테스트의 예 - 환경 제어 시스템 테스트
@Test
public void turnOnLoTempAlarmAtThreashold() throws Exception {
hw.setTemp(WAY_T00_C0LD);
controller.tic();
assertTrue(hw.heaterState());
assertTrue(hw.blowerState());
assertFalse(hw.coolerState());
assertFalse(hw.hiTempAlarm());
assertTrue(hw.loTempAlarm());
}
코드 가독성을 높인 리팩토링
@Test
public void turnOnLoTempAlarmAtThreashold() throws Exception {
wayTooCold();
assertEquals("HBchL", hw.getState());
}
가독성을 높인 테스트 코드는 이해하기 쉽다
상태 표현을 반환하는 getState()
public String getState() {
String state = "";
state += heater ? "H" : "h";
state += blower ? "B" : "b";
state += cooler ? "C" : "c";
state += hiTempAlarm ? "H" : "h";
state += loTempAlarm ? "L" : "l";
return state;
}
최초에는 실패하는 테스트를 작성하며 부담없이 로직을 정리해가는 것이니까 성능을 고려하기 보다는 로직을 알아보기 편한 코드를 우선 고려하는게 더 나은 것 같습니다.
jUnit으로 테스트 코드를 짤 때는 함수마다 assert 문을 단 하나만 사용해야 한다 주장하는 학파가 있음
테스트를 쪼개 각자가 assert를 수행할 수 있도록 구성할 수 있음
단일 assert 문의 규칙은 훌륭한 지침이지만 효율적이고 관리가 편한 코드 측면에서는 여러 assert 문을 넣어도 무관함. (assert 문 개수는 최대한 줄여야 좋다.)
이것저것 잡다한 개념을 연속으로 테스트 하는 긴 함수는 피한다.
여러 개념을 한 함수로 몰아넣으면 독자가 각 절이 존재하는 이유와 각 절이 테스트 하는 개념을 모두 이해해야 하기 때문
여러 개념이 모여있는 테스트 코드의 예
테스트를 셋으로 분리한다면?
장황한 테스트 코드 속 일반적인 규칙이 드러난다.
가장 좋은 테스트 규칙 ⇒ 한 테스트에는 개념 당 assert 문 수를 최소로 줄이고, 테스트 함수 하나는 개념 하나만 테스트 할 것!
하나의 테스트에는 한 두개의 개념을 확인하는 assert를 작성하고 (알고 싶은 단언은 전부 작성해버리고) 개념은 하나만 테스트, 부족한건 다른 테스트에서 확인하는 방식으로 정리할 수 있을 것 같습니다.
독립적으로 수행하지 못하는 테스트를 작성할 때는 @Ignore를 설정해 사유를 적어두긴 했지만 어떻게 해야 하는지 고민입니다.