흔한 QA 엔지니어

Katalon Studio & Jira 연동하기 본문

Test Automation/Jira

Katalon Studio & Jira 연동하기

블로그 닉네임 입력 제한 수는 몇 자인가요? 2025. 4. 3. 15:12

Katalon Studio에서 실행한 테스트케이스 내 에러 발생 시
Jira 내 자동으로 이슈를 생성하기 위함입니다!

개발팀에서 바로 확인 가능하도록
에러 당시 스크린샷 + 테스트케이스 스크립트 파일을 첨부했습니다.
또한 Jira 프로젝트 내 중복 체크 후 이미 존재할 경우
기존 이슈 내 코멘트만 작성합니다.

1. Jira 연동 정보 확인

Katalon Studio 연동을 위해 여러 정보에 대한 확인이 필요합니다.

Jira 계정 정보(이메일 형식 ID) / 프로젝트 Key / API Token / 프로젝트 URL /  / Jira Issue Type

프로젝트 > 보드 설정 > Jira 프로젝트 Key 확인
계정 설정 > 보안 > API 토큰 만들기 > API Key 확인
프로젝트 URL 입력 예시 : https://test-team.atlassian.net/
프로젝트 설정 > 이슈 유형 > 스토리 / 버그 / 에픽 / 작업 / 하위 작업이 있어요. 저는 작업(Task)으로 선택했습니다.

 

2. Katalon Studio 내 .gitignore 파일 및 console.properties 설정

console.properties를 통해 인증 정보나 환경 설정을 코드와 분리하면
형상 관리 도구에 소스코드를 안전하게 공유할 수 있고
API 키 등 민감 정보 노출을 예방할 수 있습니다.

# .gitignore 파일

# Katalon reports
Reports/

# Test results
bin/
Libs/

# IntelliJ or Eclipse files
*.iml
*.classpath
*.project
.idea/
.settings/

# 민감 설정
Include/resources/console.properties
# Include/resources/console.properties 파일

# Jira
jira.username=test@test.com // Jira 이메일 형식 계정
jira.api.token= // JIRA API 토큰
jira.url=https://test-team.atlassian.net/ // Jira 프로젝트 URL
jira.project.key= TEST // Jira 프로젝트 키
jira.issue.type=Task // Jira 프로젝트 내 자동으로 생성할 이슈 타입

 

3. Test Listener 생성

Test Listeners > New > New Test Listener로 생성합니다.

Test Listener 파일 내 자동 method 생성 옵션
옵션 선택 후 리스너 파일 생성 시 코드 작성에 용이하지만 저희는 붙여넣기 할거니까요
Generate sample Before Test Case method - Test Case 실행 전 호출되는 method
Generate sample After Test Case method - Test Case 실행 후 호출되는 method
Generate sample Before Test Suite method - Test Suite 실행 전 호출되는 method
Generate sample After Test Suite method - Test Suite 실행 후 호출되는 method

 

4. 스크립트 작성

주석 참고해주세요

import com.kms.katalon.core.annotation.*
import com.kms.katalon.core.context.TestCaseContext
import com.kms.katalon.core.util.KeywordUtil
import com.kms.katalon.core.webui.keyword.WebUiBuiltInKeywords as WebUI
import com.kms.katalon.core.configuration.RunConfiguration

import java.util.Properties
import java.net.HttpURLConnection
import java.net.URL
import groovy.json.JsonSlurper
import org.apache.commons.text.StringEscapeUtils

class JiraTestListener {
	// 테스트케이스 실행 후 실패 케이스를 Jira 프로젝트 내 자동 생성
    @AfterTestCase
    def afterTestCase(TestCaseContext testCaseContext) {
        if (testCaseContext.getTestCaseStatus() == 'FAILED') {
            Properties props = loadProps()
            String jiraUsername = props.getProperty("jira.username")
            String jiraApiToken = props.getProperty("jira.api.token")
            String jiraUrl = props.getProperty("jira.url")
            String projectKey = props.getProperty("jira.project.key")
            String issueType = props.getProperty("jira.issue.type")
            String encodedAuth = "${jiraUsername}:${jiraApiToken}".bytes.encodeBase64().toString()

            String testCaseId = testCaseContext.getTestCaseId()
			String testCaseName = testCaseId.split("/")[-1]   // LoginSuccess
			String summary = "Katalon Test Failure: ${testCaseName}"
            String errorMessage = testCaseContext.getMessage() ?: "No error message"
			String failedLine = extractFailedLine(errorMessage)
            String timestamp = new Date().format("yyyy-MM-dd HH:mm:ss")

            // 중복 이슈 검색
            String issueKey = findExistingIssue(testCaseId, jiraUrl, encodedAuth)
            if (issueKey != null) {
                KeywordUtil.logInfo("중복 이슈 감지 - ${issueKey}, 코멘트만 추가")
                addCommentToJiraIssue(jiraUrl, issueKey, errorMessage, encodedAuth)
                return
            }

            // 새 이슈 생성
            def descriptionText = """
### ❌ Katalon Test Failure

- **Test Case**: ${testCaseId}
- **Failed Line**: ${failedLine}
- **Status**: FAILED
- **Timestamp**: ${timestamp}
- **Error**: ${errorMessage}
"""

            String escapedDescription = StringEscapeUtils.escapeJson(descriptionText)
            String requestBody = """
{
"fields": {
"project": { "key": "${projectKey}" },
"summary": "${summary}",
"description": "${escapedDescription}",
"issuetype": { "name": "${issueType}" }
}
}
"""

            URL issueUrl = new URL("${jiraUrl}/rest/api/2/issue")
            HttpURLConnection conn = (HttpURLConnection) issueUrl.openConnection()
            conn.setRequestMethod("POST")
            conn.setDoOutput(true)
            conn.setRequestProperty("Authorization", "Basic " + encodedAuth)
            conn.setRequestProperty("Content-Type", "application/json")
            conn.outputStream.write(requestBody.getBytes("UTF-8"))

            int responseCode = conn.getResponseCode()
            if (responseCode == 201) {
                def json = new JsonSlurper().parseText(conn.inputStream.text)
                issueKey = json.key
                KeywordUtil.logInfo("Jira 이슈 생성 완료: ${issueKey}")

                // 에러 발생 당시 스크린샷 첨부
                String screenshotPath = RunConfiguration.getReportFolder() + "/Failed_${System.currentTimeMillis()}.png"
                WebUI.takeScreenshot(screenshotPath)
                File screenshotFile = new File(screenshotPath)
                if (screenshotFile.exists()) {
                    uploadFileToJira(jiraUrl, issueKey, screenshotFile, encodedAuth)
                }

                // 테스트 케이스 스크립트 첨부
                File scriptFile = findTestCaseScriptFile(testCaseId)
                if (scriptFile != null && scriptFile.exists()) {
                    uploadFileToJira(jiraUrl, issueKey, scriptFile, encodedAuth)
                } else {
                    KeywordUtil.logInfo("테스트 케이스 스크립트를 찾을 수 없습니다.")
                }

            } else {
                KeywordUtil.markFailed("Jira 이슈 생성 실패 - 응답 코드: ${responseCode}")
            }
        }
    }
	// Failed Line 추출 함수
	private String extractFailedLine(String message) {
		def matcher = message =~ /at (\w+)\.run\((.+):(\d+)\)/
		return matcher.find() ? "${matcher.group(2)}: Line ${matcher.group(3)}" : "Unknown"
	}
	
	// 테스트 케이스 스크립트 파일 탐색
	private File findTestCaseScriptFile(String testCaseId) {
		if (!testCaseId?.startsWith("Test Cases/")) return null
		String relativePath = testCaseId.replace("Test Cases/", "").trim()
		File folder = new File(RunConfiguration.getProjectDir() + "/Scripts/" + relativePath)
		if (!folder.exists() || !folder.isDirectory()) return null
		return folder.listFiles()?.find { it.name.endsWith(".groovy") }
	}
	

    // 중복 이슈 확인 (열린 이슈 중 summary 동일한 것 검색)
	private String findExistingIssue(String testCaseId, String jiraUrl, String auth) {
	    Properties props = loadProps()
	    String projectKey = props.getProperty("jira.project.key")
	
	    // 키워드만 추출
	    String keyword = testCaseId.split("/")[-1]  // LoginSuccess
		
		// Done 상태의 이슈는 찾지 않음
		String jql = "project = ${projectKey} AND summary ~ '${keyword}' AND statusCategory != Done"
        
        // 모든 상태의 이슈 찾기
        // String jql = "project = ${projectKey} AND summary ~ '${keyword}'"

	
	    KeywordUtil.logInfo("중복 체크 JQL (raw): ${jql}")
	
	    String encodedJql = URLEncoder.encode(jql, "UTF-8")
	    URL searchUrl = new URL("${jiraUrl.replaceAll("/+\$", "")}/rest/api/2/search?jql=${encodedJql}")
	
	    HttpURLConnection conn = (HttpURLConnection) searchUrl.openConnection()
	    conn.setRequestMethod("GET")
	    conn.setRequestProperty("Authorization", "Basic " + auth)
	    conn.setRequestProperty("Content-Type", "application/json")
	
	    def responseText = conn.inputStream.text
	    KeywordUtil.logInfo("Jira 응답: ${responseText}")
	
	    def json = new JsonSlurper().parseText(responseText)
	    if (json.issues && json.issues.size() > 0) {
	        KeywordUtil.logInfo("중복 이슈 발견됨: " + json.issues[0].key)
	        return json.issues[0].key
	    }
	
	    KeywordUtil.logInfo("중복 이슈 없음 → 새로 생성")
	    return null
	}

    // 기존 이슈에 코멘트 추가
    private void addCommentToJiraIssue(String jiraUrl, String issueKey, String message, String auth) {
        def payload = [
            body: "재발생한 에러:\n```\n${message.take(400)}\n```"
        ]
        String commentJson = new groovy.json.JsonBuilder(payload).toString()

        URL url = new URL("${jiraUrl}/rest/api/2/issue/${issueKey}/comment")
        HttpURLConnection conn = (HttpURLConnection) url.openConnection()
        conn.setRequestMethod("POST")
        conn.setDoOutput(true)
        conn.setRequestProperty("Authorization", "Basic " + auth)
        conn.setRequestProperty("Content-Type", "application/json")
        conn.outputStream.write(commentJson.getBytes("UTF-8"))

        if (conn.responseCode == 201) {
            KeywordUtil.logInfo("코멘트 추가 완료 - 이슈 ${issueKey}")
        } else {
            KeywordUtil.logInfo("코멘트 추가 실패: ${conn.responseCode}")
        }
    }

	// Jira 내 파일 업로드
    private void uploadFileToJira(String jiraUrl, String issueKey, File file, String auth) {
        String boundary = "----Boundary" + System.currentTimeMillis()
        URL url = new URL("${jiraUrl}/rest/api/2/issue/${issueKey}/attachments")
        HttpURLConnection conn = (HttpURLConnection) url.openConnection()
        conn.setDoOutput(true)
        conn.setRequestMethod("POST")
        conn.setRequestProperty("Authorization", "Basic " + auth)
        conn.setRequestProperty("X-Atlassian-Token", "no-check")
        conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=${boundary}")

        OutputStream output = conn.getOutputStream()
        PrintWriter writer = new PrintWriter(new OutputStreamWriter(output, "UTF-8"), true)

        writer.append("--${boundary}\r\n")
        writer.append("Content-Disposition: form-data; name=\"file\"; filename=\"${file.getName()}\"\r\n")
        writer.append("Content-Type: application/octet-stream\r\n\r\n")
        writer.flush()

        FileInputStream input = new FileInputStream(file)
        byte[] buffer = new byte[4096]
        int bytesRead
        while ((bytesRead = input.read(buffer)) != -1) {
            output.write(buffer, 0, bytesRead)
        }
        output.flush()
        writer.append("\r\n--${boundary}--\r\n")
        writer.flush()
        writer.close()
        input.close()

        if (conn.responseCode in [200, 201]) {
            KeywordUtil.logInfo("파일 첨부 완료: ${file.name}")
        } else {
            KeywordUtil.logInfo("파일 첨부 실패: ${conn.responseCode}")
        }
    }

    private Properties loadProps() {
        Properties props = new Properties()
        File file = new File(RunConfiguration.getProjectDir() + "/Include/resources/console.properties")
        if (file.exists()) {
            props.load(new FileInputStream(file))
        }
        return props
    }
}

 

6. Katalon Studio에서 Test Suite 실행 후 결과 확인

LoginSuccess 케이스가 실패하도록 변경 후 실행했습니다.

백로그 내 해야할 일 상태로 이슈가 생성된 것을 확인 가능합니다!
이슈 상세 내 TC ID / 에러 발생 라인 / 상태 / 시간 / 에러 로그 확인이 가능합니다.
에러 발생 당시의 스크린샷 / 테스트 케이스 스크립트를 첨부하며 중복 에러 발생 시 Jira 이슈를 신규 생성하지 않고 코멘트만 작성합니다.

주의 ) 기존에 존재하던 이슈가 Done(완료) 상태인 경우 중복 체크를 진행하지 않습니다.
모든 상태에 대한 중복 체크를 원할 경우 스크립트 내 findExistingIssue 메소드의 주석을 참고
해주세요.

7. Katalon Studio & Jira 연동 참고 영상

 

 

'Test Automation > Jira' 카테고리의 다른 글

Jira 스크럼 프로젝트 가이드  (0) 2025.03.28
Jira & Confluence 기초 가이드  (0) 2025.03.28