테스트 도구 구축
모든 테스트는 실행 시작 지점에서
1. 오픈 파이어 서버를 띄우고
2. Sniper와 경매에 필요한 계정을 생성한 다음
3. 테스트를 실행한다.
각 테스트는
1. 애플리케이션과 가짜 경매의 인스턴스들을 시작시키고
2. 이들과 서버 간의 통신을 테스트하게 된다.
애플리케이션 러너
ApplicationRunner는 현재 만들고 있는 스윙 애플리케이션과의 관리 및 통신을 총괄하는 객체다.
ApplicationRunner는
1. 마치 명령줄에서 실행된 것처럼 애플리케이션을 실행하고
2. GUI 상태를 조회하고
3. 테스트가 끝날 때 애플리케이션을 종료하기 위해 메인 창에 대한 참조를 획득해서 보관한다.
윈도리커가 궂은 일을 도맡아준다.
궂은 일은
1. 스윙 GUI 컴포넌트를 찾아서 제어하고
2. 스윙 스레드와 이벤트 큐를 동기화하며
3. 단순한 API 너머에 존재하는 온갖 것을 총괄하는 것
을 말한다.
- ComponentDriver -
스윙 사용자 인터페이스의 기능들을 조작할 수 있는 객체
ComponentDriver에서 현재 참조 중인 스윙 컴포넌트를 찾을 수 없다면 오류를 내면서 제한 시간이 초과될 것이다.
이 테스트에서는 특정 문자열을 보여주는 레이블 컴포넌트를 찾는데, 애플리케이션에서 이 레이블을 만들어내지 않는다면 예외가 발생한다.
public class ApplicationRunner {
public static final String SNIPER_ID = "sniper";
public static final String SNIPER_PASSWORD = "sniper";
private AuctionSniperDriver driver;
public void startBiddingIn(final Fake AuctionServer auction) {
@Override public void run() { // 1
try {
Main.main(XMPP_HOSTNAME, SNIPER_ID, SNIPER_PASSWORD,
auction.getItemId()); // 2
} catch (Exception e) {
e.printStackTrace(); // 3
}
};
thread.setDaemon(true);
thread.start();
driver = new AuctionSniperDriver(1000); // 4
driver.showsSniperStatus(STATUS_JOINING); // 5
}
public void showsSniperHasLostAuction() {
driver.showSniperStatus(STATUS_LOST); // 6
}
public void stop() {
if (driver != null) {
driver.dispose(); // 7
}
}
}
/** 1
* 1 코드의 각 부분을 올바르게 조합했는지 확인하고자 애플리케이션의 main()함수를 통해 애플리케이션을 호출한다.
* 여기서는 최상위 수준 패키지에 있는 Main 클래스가 애플리케이션의 진입점이라는 관례를 따른다.
* 윈도리커가 스윙 컴포넌트와 같은 같은 JVM 상에 있다면 윈도리커가 스윙 컴포넌트를 제어할 수 있으므로
* 스나이퍼가 새 스레드에서 시작하게 했다.
* 이상적인 경우라면 테스트가 스나이퍼를 새 프로세스에서 구동할 테지만 그렇게 하면 테스트하기가 훨씬 더 어렵다.
*/
/** 2
* 2단계에서는 복잡하지 않게 한 품목에 대해서만 입찰하고 식별자를 main()으로 전달한다고 가정한다.
*/
/** 3
* main()에서 예외를 던지면 여기서는 예외를 출력하기만 한다.
* 실행중인 테스트가 실패하면 출력 화면의 스택 트레이스를 살펴볼 수 있다.
* 나중에 예외를 좀 더 적절히 처리하겠다.
*/
/** 4
* 프레임과 컴포넌트를 찾기 위해 제한 시간 주기를 줄였다.
* 기본값은 이 예제와 같은 간단한 애플맄이션에 필요한 것 치고는 길어서 실패하면 테스트가 느려질 것이다.
* 여기서는 1초를 사용했으며, 이 정도면 사소한 실행 시간 지연에 비하면 충분히 매끄러운 수준이다.
*/
/** 5
* 애플리케이션이 접속을 시도했는지 파악하고자 상태가 Joining으로 바뀌길 기다린다.
* 이 단정은 사용자 인터페이스 어딘가에 스나이퍼 상태를 표시하는 레이블이 있음을 말해준다.
*/
/** 6
* 스나이퍼가 경매에서 낙찰하지 못하면 Lost 상태를 보여줄 것으로 예상한다.
* 이렇게 되지 않으면 드라이버가 예외를 던질 것이다.
*/
/** 7
* 테스트가 끝나면 드라이버가 창을 없애게 해서
* 가비지 컬렉션이 완료되기 전에 다른 테스트에서 창을 사용하는 것을 방지한다.
*/
가짜 경매
FakeAuctionService는 대체 서버로서,
테스트에서는 이것을 이용해 경매 스나이퍼가 어떻게 XMPP 메시지를 사용해 경매와 상호작용하는지 검사할 수 있다.
FakeAuctionServer에는 3가지 책임이 있다.
1. XMPP 브로커에 접속해 스나이퍼와의 채팅에 참여하라는 요청을 수락해야 한다.
2. 스나이퍼로부터 채팅 메시지를 받거나 특정 제한 시간 내에 아무런 메시지도 받지 못하면 실패해야 한다.
3. 사우스비 온라인에서 명시한 대로 테스트에서 스나이퍼로 메시지를 되돌려 보낼 수 있게 해야 한다.
스맥(XMPP 클라이언트 라이브러리)은 이벤트 구동형이다.
그러므로 가짜 경매에서는 해당 경매에서 콜백을 하기 위해 리스너 객체를 등록해야 한다.
이벤트에는 두 가지 수준이 있다.
1. 채팅에 관한 이벤트 (사람들이 채팅에 참여하는 것 등)
2. 채팅 내 이벤트 ('메시지를 수신 중' 같은 것 등)
두 가지 이벤트를 모두 대기한다.
startSellingItem() 메서드를 구현하는 것으로 시작하자.
이 메서드에서는
1. XMPP 브로커로 접속
2. 품목 식별자를 이용해 로그인 이름을 생성
3. 로그인 이름을 ChatManagerListener에 등록
4. 스맥에서는 스나이퍼가 채팅에 접속했을 때 세션을 나타내는 Chat 객체와 함께 이 리스너를 호출한다.
5. 가짜 경매에서는 스나이퍼와 메시지를 교환할 수 있게 해당 채팅에 대한 참조를 보관한다.
코드는 다음과 같다.
public class FakeAuctionServer {
public static final String ITEM_ID_AS_LOGIN = "auction-%s";
public static final String AUCTION_RESOURCE = "Auction";
public static final String XMPP_HOSTNAME = "localhost";
private static final String AUCTION_PASSWORD = "auction";
private final String itemId;
private final XMPPConnection connection;
private Chat currentChat;
public FakeAuctionServer(String itemId) {
this.itemId = itemId;
this.connection = new XMPPConnection(XMPP_HOSTNAME);
}
public void startSellingItem() throws XMPPException {
connection.connect();
connection.login(format(ITEM_ID_AS_LOGIN, itemId),
AUCTION_PASSWORD, AUCTION_RESOURCE);
connection.getChatManager().addChatListener(
new ChatManagerListener() {
public void chatCreated(Chat chat, boolean createdLocally) {
currentChat = chat;
}
});
}
public String getItemId() {
return itemId;
}
}
이 가짜 구현체가 단순히 테스트를 보조하는 용도라는 점을 다시근 강조하고 싶다. 이를테면, 인스턴스 변수 하나를 사용해 채팅 객체를 보관한다. 실제 경매 서버라면 모든 입찰자에 대해 다수의 채팅을 관리하겠지만 이것은 가짜에 불과하다. 가짜 구현의 유일한 용도는 테스트를 지원하는 데 있으므로 채팅은 하나만 필요하다.
다음으로 스나이퍼가 보내는 메시지를 받으려면 chat에 MessageListener를 추가해야 한다. 이는 테스트를 실행하는 스레드와 메시지를 리스너에 보내는 스맥 스레드 간에 조율이 필요하다는 의미다. 테스트에서는 메시지가 도착하길 대기하고 메시지가 도착하지 않으면 제한 시간이 초과돼야 한다. 그러므로 요소가 하나인 java.util.concurrent 패키지의 BlockingQueue를 사용하겠다. 테스트에는 chat이 딱 하나밖에 없으므로 한 번에 메시지 하나만 처리할 것으로 예상한다. 의도를 분명하게 하고자 큐를 SingleMessageListener 도우미 클래스로 감싼다.
다음은 FakeAuctionServer의 나머지 코드다.
import java.util.concurrent.ArrayBlockingQueue;
public class FakeAuctionServer {
public static final String ITEM_ID_AS_LOGIN = "auction-%s";
public static final String AUCTION_RESOURCE = "Auction";
public static final String XMPP_HOSTNAME = "localhost";
private static final String AUCTION_PASSWORD = "auction";
private final String itemId;
private final XMPPConnection connection;
private Chat currentChat;
private final SingleMessageListner messageListener =
new SingleMessageLisner();
public FakeAuctionServer(String itemId) {
this.itemId = itemId;
this.connection = new XMPPConnection(XMPP_HOSTNAME);
}
public void startSellingItem() throws XMPPException {
connection.connect();
connection.login(format(ITEM_ID_AS_LOGIN, itemId),
AUCTION_PASSWORD, AUCTION_RESOURCE);
connection.getChatManager().addChatListener(
new ChatManagerListener() {
public void chatCreated(Chat chat, boolean createdLocally) {
currentChat = chat;
chat.addMessageListener(messageListener);
}
});
}
public void hasReceivedJoinRequestFromSniper() throws InterruptedException {
messageListener.receivesAMessage(); // 1
}
public void announceClosed() throws XMPPException {
currentChat.sendMessage(new Message()); // 2
}
public void stop() {
connection.disconnect(); // 3
}
public String getItemId() {
return itemId;
}
}
public class SingleMessageListener implements MessageListener {
private final ArrayBlockingQueue<Message> messages =
new ArrayBlockingQueue<Message>(1);
public void processMessage(Chat chat, Message message) {
messages.add(message);
}
public receivesAMessage() throws InterruptedException {
assertThat("Message", messages.poll(5, SECONDS), is(notNullValue())); // 4
}
}
/** 1
* 테스트에서는 Join 메시지가 언제 도착하는지 알 필요가 있다.
* 여기서는 그냥 아무 메시지나 도착했는지 검사하기만 하는데,
* 는 스나이퍼가 처음에는 Jon 메시지만 보낼 것이기 때문이다.
*/
/** 2
* 테스트에서는 경매가 종료될 때 경매 종료 선언을 흉내낼 수 있어야 한다.
* 이는 경매가 시작됐을 때 currentChat을 보관하는 이유다.
* Join 요청과 마찬가지로 가짜 경매에서는 빈 메시지를 보낸다.
* 이유는 지금까지 지원하는 유일한 이벤트이기 때문이다.
*/
/** 3
* stop에서는 연결을 닫는다
*/
/** 4
* is(notNullValue()) 절에서는 햄스크레트 매처 문법을 쓴다.
* 지금은 제한 시간 내에 메시지를 받았는지 검사한다는 것만 알면 된다.
*/