github login을 구현하며 외부 api가 포함된 비지니스 로직을 테스트하기 위한 시도와 실패에 대한 포스팅입니다!
자세한 코드는 공식 팀의 깃허브에서 확인 하실 수 있습니다.
현재 Github OAuth를 이용하여 위와 같은 순서의 로직으로 로그인 API를 구현하였습니다.
6, 8번과 같은 상황에서 아래 코드 처럼 외부 API를 사용하게 됩니다.
BASE_URL+GITHUB_ACCESS_URL_SUFFIX
= "https://github.com"
+ "/login/oauth/access_token"
으로 요청
private GithubAccessTokenResponse getGithubAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);
GithubAccessTokenRequest accessTokenRequest =new GithubAccessTokenRequest(clientId, clientSecret, code);
HttpEntity<GithubAccessTokenRequest> entity = new HttpEntity<>(accessTokenRequest, headers);
GithubAccessTokenResponse accessTokenResponse = restTemplate.exchange(
BASE_URL+GITHUB_ACCESS_URL_SUFFIX,
HttpMethod.POST,
entity,
GithubAccessTokenResponse.class
).getBody();
validateToken(accessTokenResponse);
return accessTokenResponse;
}
PROFILE_URL
= "https://api.github.com/user"
으로 요청
private GithubProfileResponse getGithubProfile(GithubAccessTokenResponse accessTokenResponse) {
String accessToken = accessTokenResponse.getAccessToken();
String token = TOKEN + accessToken;
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.AUTHORIZATION, token);
httpHeaders.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);
HttpEntity<Void> httpEntity =newHttpEntity<>(httpHeaders);
GithubProfileResponse profileResponse = restTemplate.exchange(
PROFILE_URL,
HttpMethod.GET,
httpEntity,
GithubProfileResponse.class,
objectMapper
).getBody();
validateProfile(profileResponse);
return profileResponse;
}
이런 상황에서 외부 API를 Mocking하여 테스트를 시도했던 과정을 공유합니다.
github 서버와 요청을 주고 받는 인터페이스 OAuthClient를 만들어서 메인 코드에서 사용할 구현체와 테스트코드에서 사용할 구현체를 분리하여 테스트해야겠다고 생각했습니다.
OAuthClient
를 상속받는 FakeAuthClient
구현public class FakeAuthClient implements OAuthClient{
private static final String BASE_URL= "https://github.com";
private static final String LOGIN_URL_SUFFIX= "/login/oauth/authorize?client_id=%s&redirect_uri=%s";
private static final String CLIENT_ID= "clientId";
private static final String REDIRECT_URL= "http://localhost:8080/callback";
@Override
public String getRedirectUrl() {
return String.format(BASE_URL+LOGIN_URL_SUFFIX,CLIENT_ID,REDIRECT_URL);
}
@Override
public GithubProfileResponse getMemberProfile(String code) {
return GithubClientFixtures.getGithubProfile(code);
}
}
@RequestMapping("/api/auth")
@TestConstructor(autowireMode = AutowireMode.ALL)
@RestController
public classFakeAuthController {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
public FakeAuthController(MemberRepository memberRepository, JwtTokenProvider jwtTokenProvider) {
this.memberRepository = memberRepository;
this.jwtTokenProvider = jwtTokenProvider;
this.authService = new AuthService(newFakeAuthClient(), memberRepository, jwtTokenProvider);
}
@PostMapping("/fake/token")
public ResponseEntity<TokenResponse> login(@RequestBody OAuthCodeRequest OAuthCodeRequest) {
TokenResponse token = authService.generateAccessToken(OAuthCodeRequest);
return ResponseEntity.ok(token);
}
}
그냥 OAuthClient
를 MockBean을 이용하여 Mocking
githubOAuthClient
가 요청을 보내는 것을 Mocking
방법
해결 방안 1보단 해결 방안 2가 더 최소한의 Mocking을 한다고 판단하였습니다.
해결 방안 2의 방법 모두 결과 및 효과는 똑같다고 판단되어 구현이 간편한 후자로 다시 외부 API 테스트를 진행하게 되었습니다.
@Autowired
private RestTemplate restTemplate;
private MockRestServiceServer mockServer;
private ObjectMapper objectMapper = new ObjectMapper();
private void mockGithubServer(String githubId, String name, String avatarUrl)
throws JsonProcessingException, URISyntaxException {
mockServer = MockRestServiceServer.createServer(restTemplate);//1
mockServer.expect(requestTo("https://github.com/login/oauth/access_token"))//2
.andExpect(method(HttpMethod.POST))//3
.andRespond(withStatus(HttpStatus.OK)//4
.contentType(MediaType.APPLICATION_JSON)//5
.body(objectMapper.writeValueAsString(newTokenResponse("token1"))));//6
mockServer.expect(requestTo(newURI("https://api.github.com/user")))
.andExpect(method(HttpMethod.GET))
.andRespond(withStatus(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(objectMapper.writeValueAsString(
newGithubProfileResponse(githubId, name, avatarUrl))));
}
mockServer 생성
매번 생성 해야하는 이유 → 하나의 URL에 대한 response값을 교체해야할 경우 매번 새로해야하기 때문입니다.
요청이 오길 기대하는 URL
http method
response status code
response contenType
response body
인수테스트에서 사용되는 API를 모두 Fixture 클래스에 static 메서드로 모아놓았습니다.
하지만 여기에 mockingGithubServer 메서드를 추가하게 된다면, mockingGithubServer에서 사용하는 RestTemplate, ObjectMapper도 static으로 해야합니다.
public static TokenResponse 로그인을_한다(RestTemplate restTemplate, GithubClientFixtures client) {
try{
mockGithubServer(restTemplate, client.getGithubId(), client.getName(), client.getAvatarUrl());
}catch(JsonProcessingException | URISyntaxException e) {
e.printStackTrace();
}
returnRestAssured
.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(newOAuthCodeRequest(client.getCode()))
.when()
.post("/api/auth/fake/token")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.extract()
.as(TokenResponse.class);
}
하지만 RestTemplate가 Bean이므로 static으로 설정할 수 없는 문제가 생겼습니다.
TokenResponse tokenResponse = 로그인을_한다(restTemplate, 주디);
생각한 해결 방안도 팀원들과 의견을 나눠봤을 때 가독성이 떨어진다는 단점과 중복 코드가 발생한다는 단점이 있어 다른 방법을 통해 테스트를 진행했습니다.
GithubOAuthClient가 요청을 보내는 것을 Mocking하는 방안 중 1안인 요청 보내는 url을 환경 변수로 암호화하여 테스트용 github url을 만들어서 처리하도록 다시 외부 API 테스트를 진행하기로 하였습니다.
public GithubOAuthClient(
@Value("${security.oauth2.client-id}") String clientId,
@Value("${security.oauth2.client-secret}") String clientSecret,
@Value("${github.url.base}") String baseUrl,
@Value("${github.url.profile}") String profileUrl,
@Value("${github.url.redirect}") String redirectUrl,
ObjectMapper objectMapper,
RestTemplate restTemplate
) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.baseUrl = baseUrl;
this.profileUrl = profileUrl;
this.redirectUrl = redirectUrl;
this.objectMapper = objectMapper;
this.restTemplate = restTemplate;
}
@RequestMapping("/api/auth")
@RestController
public class FakeGithubController {
@PostMapping("/login/oauth/access_token")
public ResponseEntity<GithubAccessTokenResponse> getAccessToken(
@RequestBody GithubAccessTokenRequest tokenRequest) {
try {
return ResponseEntity.ok(GithubClientFixtures.getAccessToken(tokenRequest.getCode()));
} catch (IllegalStateException e) {
return ResponseEntity.ok(null);
}
}
@GetMapping("/user")
public ResponseEntity<GithubProfileResponse> getProfile(
@RequestHeader(value = HttpHeaders.AUTHORIZATION) String accessToken) {
String token = accessToken.replaceAll("token ", "");
try {
return ResponseEntity.ok(GithubClientFixtures.getGithubProfileByToken(token));
} catch (IllegalStateException e) {
return ResponseEntity.ok(null);
}
}
}
현재는 github server mocking하는 방법으로 테스트를 진행하고 있습니다.
이유는 가장 최소한의 mocking으로 테스트 커버리지를 높일 수 있었고, 팀원들의 테스트 가독성과 저희팀에서는 아직까지 최소한의 유지보수 비용이 드는 테스트 방법이라고 생각했기 때문입니다.
테스트 커버리지 지표가 높다고 꼭 좋은 테스트는 아니지만, 외부 API를 테스트하는 다양한 방법을 생각해보는 기회가 있어서 재밌었습니다~~!