😩 벌레같은 오류
이제 1.5.7 버전으로 정말 멋지게 코드를 작성했고 일본어 지원까지 했으니 이제 스토어에 업로드를 해볼까...
했으나.. 아래와 같은 오류를 보았다.
아... 항상 느끼는 거지만 오류는 막기 힘들다.
아무리 코드를 잘 짜도 테스트가 없으면 확신할 수 없다는 생각이 들었다.
때문에, 테스트를 작성하려 한다.
📚 테스트 코드 작성
근데 어떻게 하지?
이 프로그램은 GPT의 도움을 많이 받았다. 고로 GPT도 메인테이너의 자격이 있다고 본다.
그러니까 GPT는 테스트 코드를 작성할 책임이 있다!(?)
아무튼 GPT의 도움을 받았다.
간단히 코드를 보여주고 유닛 테스트를 작성해 달라 요청했다.
내가 요청한 코드는 아래의 테스트 코드였고,
import { getYTVideoId } from "../../utils/common.js"
import { getRelatedLangCodes } from "../../utils/lang.js"
/**
* @typedef {Object} CcTagModel
* @property {(backgroundColor: string) => void} setBackgroundColor
* @property {(textColor: string) => void} setTextColor
* @property {(fontSize: string) => void} setFontSize
* @property {(language: string) => void} setLanguage
* @property {(isCombinedRegion: boolean) => void} setIsCombinedRegion
* @property {function} backgroundColor
* @property {function} textColor
* @property {function} fontSize
* @property {() => string} shownLanguage
* @property {() => string[]} relatedLanguages
* @property {(videoUrl: string, languages: string[]) => Promise<void>} hasCaptions
*/
/**
* CC Tag Model
* @param {string} __backgroundColor
* @param {string} __textColor
* @param {string} __fontSize
* @param {string} __language
* @param {boolean} __isCombinedRegion
* @returns {CcTagModel}
*/
export const CcTagModel = (__backgroundColor, __textColor, __fontSize, __language, __isCombinedRegion) => {
let _backgroundColor = __backgroundColor
let _textColor = __textColor
let _fontSize = __fontSize
let _language = __language
let _isCombinedRegion = __isCombinedRegion
const setBackgroundColor = backgroundColor => _backgroundColor = backgroundColor
const setTextColor = textColor => _textColor = textColor
const setFontSize = fontSize => _fontSize = fontSize
const setLanguage = language => _language = language
const setIsCombinedRegion = isCombinedRegion => _isCombinedRegion = isCombinedRegion
const backgroundColor = () => _backgroundColor
const textColor = () => _textColor
const fontSize = () => _fontSize
const relatedLanguages = () => {
if (_isCombinedRegion) {
return getRelatedLangCodes(_language)
} else {
return [_language]
}
}
const shownLanguage = () => {
if (_isCombinedRegion) {
return _language.split('-')[0]
} else {
return _language
}
}
/**
* check
* @param {string} videoUrl
* @param {string[]} languages
*/
const hasCaptions = async (videoUrl, languages) => {
// URL example : /watch?v=[video_id]
const videoId = getYTVideoId(videoUrl)
return chrome.runtime.sendMessage({
type: 'has-captions',
value: { videoId, languages },
})
}
return {
setBackgroundColor,
setTextColor,
setFontSize,
setLanguage,
setIsCombinedRegion,
backgroundColor,
textColor,
fontSize,
shownLanguage,
relatedLanguages,
hasCaptions
}
}
GPT는 순순히 테스트 코드를 내놓았다.
아래 코드는 단순히 예시일 뿐이고 이걸 토대로 작성하면 되겠다.
import { CcTagModel } from "./CcTagModel";
import { getYTVideoId } from "../../utils/common.js";
import { getRelatedLangCodes } from "../../utils/lang.js";
jest.mock("../../utils/common.js", () => ({
getYTVideoId: jest.fn().mockReturnValue("abc123")
}));
jest.mock("../../utils/lang.js", () => ({
getRelatedLangCodes: jest.fn().mockReturnValue(["en", "fr", "es"])
}));
describe("CcTagModel", () => {
let ccTagModel;
beforeEach(() => {
ccTagModel = CcTagModel("white", "black", "14px", "en-US", true);
});
it("should have the correct backgroundColor", () => {
expect(ccTagModel.backgroundColor()).toBe("white");
});
it("should have the correct textColor", () => {
expect(ccTagModel.textColor()).toBe("black");
});
it("should have the correct fontSize", () => {
expect(ccTagModel.fontSize()).toBe("14px");
});
it("should have the correct shownLanguage", () => {
expect(ccTagModel.shownLanguage()).toBe("en");
});
it("should have the correct relatedLanguages", () => {
expect(ccTagModel.relatedLanguages()).toEqual(["en", "fr", "es"]);
});
it("should call getYTVideoId with the correct videoUrl", async () => {
await ccTagModel.hasCaptions("https://www.youtube.com/watch?v=test123", ["en", "fr"]);
expect(getYTVideoId).toHaveBeenCalledWith("https://www.youtube.com/watch?v=test123");
});
it("should send the correct message with type 'has-captions'", async () => {
const spy = jest.spyOn(chrome.runtime, "sendMessage");
await ccTagModel.hasCaptions("https://www.youtube.com/watch?v=test123", ["en", "fr"]);
expect(spy).toHaveBeenCalledWith({
type: 'has-captions',
value: { videoId: "abc123", languages: ["en", "fr"] },
});
});
});
아래와 같이 작성했다.
import { CcTagModel } from './CcTagModel';
describe('CcTagModel', () => {
/** @type {CcTagModel} */
let ccTagModel;
const defaultBackgroundColor = '#ffffffff'
const defaultTextColor = '#ffffff'
const defaultFontSize = '1.0rem'
const defaultLanguage = 'en'
const defaultCombinedRegion = true
beforeEach(() => {
ccTagModel = CcTagModel(defaultBackgroundColor, defaultTextColor, defaultFontSize, defaultLanguage, defaultCombinedRegion);
});
it('should have the correct backgroundColor', () => {
expect(ccTagModel.backgroundColor()).toBe(defaultBackgroundColor);
});
it('should have the correct textColor', () => {
expect(ccTagModel.textColor()).toBe(defaultTextColor);
});
it('should have the correct fontSize', () => {
expect(ccTagModel.fontSize()).toBe(defaultFontSize);
});
it('should have the correct shownLanguage', () => {
expect(ccTagModel.shownLanguage()).toBe(defaultLanguage);
});
it('should have the correct related languages', () => {
expect(ccTagModel.relatedLanguages().sort()).toEqual(['en', 'en-CA', 'en-IN', 'en-IE', 'en-GB', 'en-US'].sort());
});
it('setBackgroundColor should change backgroundColor', () => {
const newBackroundColor = '#00000000'
ccTagModel.setBackgroundColor(newBackroundColor)
expect(ccTagModel.backgroundColor()).toBe(newBackroundColor)
})
it('setTextColor should change textColor', () => {
const newTextColor = '#000000';
ccTagModel.setTextColor(newTextColor);
expect(ccTagModel.textColor()).toBe(newTextColor);
});
it('setFontSize should change fontSize', () => {
const newFontSize = '#000000';
ccTagModel.setFontSize(newFontSize);
expect(ccTagModel.fontSize()).toBe(newFontSize);
});
describe('should return correct shown language', () => {
it('en-US and not combinedRegion', () => {
ccTagModel.setIsCombinedRegion(false)
ccTagModel.setLanguage('en-US')
expect(ccTagModel.shownLanguage()).toBe('en-US');
})
it('en-US and combinedRegion', () => {
ccTagModel.setIsCombinedRegion(true)
ccTagModel.setLanguage('en-US')
expect(ccTagModel.shownLanguage()).toBe('en');
})
it('en and not combinedRegion', () => {
ccTagModel.setIsCombinedRegion(false)
ccTagModel.setLanguage('en')
expect(ccTagModel.shownLanguage()).toBe('en');
})
it('en and combinedRegion', () => {
ccTagModel.setIsCombinedRegion(true)
ccTagModel.setLanguage('en')
expect(ccTagModel.shownLanguage()).toBe('en');
})
})
describe('hasCaptions should return correct value', () => {
it('https://youtu.be/jNQXAC9IVRw has en caption', async () => {
const result = await ccTagModel.hasCaptions('https://youtu.be/jNQXAC9IVRw', ['en'])
expect(result).toBe(true)
})
it('https://youtu.be/jNQXAC9IVRw doesn\' have ko caption', async () => {
const result = await ccTagModel.hasCaptions('https://youtu.be/jNQXAC9IVRw', ['ko'])
expect(result).toBe(false)
})
})
});
테스트 결과는 아래와 같이 나왔다.
hasCaptions의 테스트 실패는 둘째치고 이게 과연 모델의 역할인가 싶었다.
hasCaptions의 내부 로직에는 chrome.runtime을 이용해 background에 데이터를 보내는 코드가 들어있다.
설사 모델의 역할이라고 해도 직접 보내는건 옳지 않아 보인다.
일단 그래서 현재는 테스트는 불가능하다. (외부 api와 통신하는건 크롬에 로드하지 않으면 테스트 할 수 없다.)
그래서 일단은 이렇게만 해두었고, 문제가 되었던 부분을 집중적으로 보려했다.
🔎 문제는 쇼츠!
크롬에서 보여준 오류만으론 왜 이런 문제가 생겼는지 알 수 없다.
따라서 utils/common.js의 getYTVideoId테스트 코드를 작성해 보고 해결하고자 했다.
getYTVideo 함수는 유튜브 동영상 url에서 비디오 id만 추출하는 메서드다.
예컨데 https://www.youtube.com/watch?v=CzL1d0Xi3jk라는 url의 비디오 id는 끝부분의 CzL1d0Xi3jk이다.
코드는 아래와 같다. 단순히 정규식 매칭을 통해 찾아낸다.
export const getYTVideoId = url => {
return url.match(/\?v=([\w-]+)/)[1]
}
getYTVideoId 테스트 코드를 작성하기 전, 예외 상황에 대해 생각해보고자 했다.
- 공백이나 null같은 값이 들어왔을 것 같은데..?
- 새로운 유형의 url 표기법이 생긴건가?
그래서 예외 상황을 메서드에서 잡아서 예외를 던지게 바꿔주었다. (커스텀 예외 클래스는 덤)
export const getYTVideoId = url => {
const matched = url.match(/\?v=([\w-]+)/)
if (!matched) {
throw new InvalidYouTubeVideoUrlError(`can't find video id of url: '${url}'`)
}
return matched[1]
}
테스트 코드도 상황에 맞게 watch?v=XXXX형식과 그렇지 않은 경우로 나눌 수 있었다.
버그는 여기서 야기된 것이 아닌것 같다.
예외 케이스까지 고려했지만 문제는 없었다.
그럼 누가 문제일까.. 싶어 빌드를 한 뒤 실행해보았다.
알고보니 쇼츠의 문제였다.
쇼츠는 쿼리 파라미터로 id를 받는 형식이 아닌 경로 파라미터로 받는 것이였다.
그래서 애초에 YtThumbnailView를 생성할때 url에 대해 검증하도록 했다.
🤔 곤란한 테스트들 - YtObserver, ContentMessageManager
YtObserver과 ContentMessageManager는 각각 MutationObserver와 chrome.runtime에 이벤트 리스너를 등록하는 일 밖에 안한다.
때문에 테스트가 곤란해지는데, 예컨데 아래와 같이 리스너 콜백이 구현에 숨겨져 있어 테스트할 수 없다.
/**
* Content Script Message Manager
* @param {MessageManager} messageManager
* @param {CcTagPresenter} ccTagPresenter
* @returns {ContentMessageManager}
*/
export const ContentMessageManager = (messageManager, ccTagPresenter) => {
messageManager.addOnMessageListener(req => {
if (LANGUAGE_FIELD in req) ccTagPresenter.onLanguageUpdated(req[LANGUAGE_FIELD])
if (COLOR_BG_FIELD in req) ccTagPresenter.onBackgroundColorUpdated(req[COLOR_BG_FIELD])
if (COLOR_TXT_FIELD in req) ccTagPresenter.onTextColorUpdated(req[COLOR_TXT_FIELD])
if (CC_PREVIEW_FONT_SIZE_FIELD in req) ccTagPresenter.onFontSizeUpdated(req[CC_PREVIEW_FONT_SIZE_FIELD])
if (IS_COMBINED_REGION_FIELD in req) ccTagPresenter.onIsCombinedRegionUpdated(req[IS_COMBINED_REGION_FIELD])
})
ContentMessageManager는 초기 함수만 호출하면 끝나게 되고, YtObserver도 비슷하게 init 함수를 호출하면 더 이상 사용할 필요가 없다.
리스너에 중점을 둬서 ContentMessageListener으로 클래스 명을 바꾸고 리스너만 다루게 했다.
등록은 context가 담당한다. (이런 건 테스트도 힘들것 같다.)
아래와 같이 변경했다.
/**
* Content Script Message Listen
* @example messageManager.addOnMessageListener(ContentMessageListener(ccTagPresenter))
* @param {CcTagPresenter} ccTagPresenter
* @returns {ContentMessageListener}
*/
export const ContentMessageListener = ccTagPresenter => {
return req => {
if (LANGUAGE_FIELD in req)
ccTagPresenter.onLanguageUpdated(req[LANGUAGE_FIELD])
if (COLOR_BG_FIELD in req)
ccTagPresenter.onBackgroundColorUpdated(req[COLOR_BG_FIELD])
if (COLOR_TXT_FIELD in req)
ccTagPresenter.onTextColorUpdated(req[COLOR_TXT_FIELD])
if (CC_PREVIEW_FONT_SIZE_FIELD in req)
ccTagPresenter.onFontSizeUpdated(req[CC_PREVIEW_FONT_SIZE_FIELD])
if (IS_COMBINED_REGION_FIELD in req)
ccTagPresenter.onIsCombinedRegionUpdated(req[IS_COMBINED_REGION_FIELD])
}
}
/**
* Content Script Context
* @returns {ContentContext}
*/
export const ContentContext = document => {
const contentMessageListener = () => {
if (!_contentMessageListener) {
_contentMessageListener = ContentMessageListener(ccTagPresenter())
}
return _contentMessageListener
}
const messageManager = () => {
if(!_messageManager) {
_messageManager = MessageManager()
_messageManager.addOnMessageListener(contentMessageListener())
}
return _messageManager
}
}
덕분에 리스너를 직접 호출할 수 있어 테스트가 수월해졌다. (아래)
import {
CC_PREVIEW_FONT_SIZE_FIELD,
COLOR_BG_FIELD,
COLOR_TXT_FIELD,
IS_COMBINED_REGION_FIELD,
LANGUAGE_FIELD,
} from '../../utils/storage.js'
import { CcTagPresenter } from './CcTagPresenter.js'
import { ContentMessageListener } from './ContentMessageListener.js'
import { jest } from '@jest/globals'
describe('ContentMessageListener', () => {
/** @type {CcTagPresenter} */
let ccTagPresenter
/** @type {ContentMessageListener} */
let contentMessageListener
beforeEach(() => {
ccTagPresenter = {
onBackgroundColorUpdated: jest.fn(),
onTextColorUpdated: jest.fn(),
onFontSizeUpdated: jest.fn(),
onLanguageUpdated: jest.fn(),
onIsCombinedRegionUpdated: jest.fn(),
}
contentMessageListener = ContentMessageListener(ccTagPresenter)
})
it('should call onLanguageUpdated on ccTagPresenter with the value of LANGUAGE_FIELD', () => {
const req = { [LANGUAGE_FIELD]: 'en' }
contentMessageListener(req)
expect(ccTagPresenter.onLanguageUpdated).toHaveBeenCalledWith('en')
})
it('should call onBackgroundColorUpdated on ccTagPresenter with the value of COLOR_BG_FIELD', () => {
const req = { [COLOR_BG_FIELD]: '#00000000' }
contentMessageListener(req)
expect(ccTagPresenter.onBackgroundColorUpdated).toHaveBeenCalledWith(
'#00000000',
)
})
it('should call onTextColorUpdated on ccTagPresenter with the value of COLOR_TXT_FIELD', () => {
const req = { [COLOR_TXT_FIELD]: '#FFFFFF' }
contentMessageListener(req)
expect(ccTagPresenter.onTextColorUpdated).toHaveBeenCalledWith('#FFFFFF')
})
it('should call onFontSizeUpdated on ccTagPresenter with the value of CC_PREVIEW_FONT_SIZE_FIELD', () => {
const req = { [CC_PREVIEW_FONT_SIZE_FIELD]: '1.2rem' }
contentMessageListener(req)
expect(ccTagPresenter.onFontSizeUpdated).toHaveBeenCalledWith('1.2rem')
})
it('should call onIsCombinedRegionUpdated on ccTagPresenter with the value of IS_COMBINED_REGION_FIELD', () => {
const req = { [IS_COMBINED_REGION_FIELD]: true }
contentMessageListener(req)
expect(ccTagPresenter.onIsCombinedRegionUpdated).toHaveBeenCalledWith(true)
})
})
💬 마무리
이번 과정은 성공적이지 못했다.
테스트에 대해 잘 모르는 부분도 많고, 테스트를 작성했지만 별 차이를 느끼지 못했다.
이번 테스트는 경험으로 두고 다음에 생각이 나면 새롭게 리팩토링 해보고 싶다.
'프로그래밍 > 유튜브 자막 표시기' 카테고리의 다른 글
유튜브 자막 표시기 리팩토링 - content_script.js (0) | 2023.02.07 |
---|---|
유튜브 자막 표시기 리팩토링 - MVP 패턴 도입 (0) | 2023.02.03 |