[OSSCA] #63 엔드포인트 리팩토링 - 3. 리팩토링2 - Test Code 작성

뚜비·2022년 10월 3일
0

2022 OSSCA

목록 보기
14/14

2022 오픈소스 컨트리뷰션 아카데미 "NHN Toast Power Platform Connector" Masters에 참여하면서 배운 내용을 기록하였습니다.


전편 3. 리팩토링과 이어집니다!



✅ Build Request

1. NullSettings

❌문제발생❌ - Test Code가 잘 안 돌아간다?!

즉 우리가 원하는 InvalidOperationException() 이외에 다른 Exception까지 잡아버리는 상태,, 따라서 해당 test는 통과하지 못 하고 실패하는 것이다! 멘토님께 여쭤보니 멘토님께서도 valid한 질문이라고 해주셨다!! (뿌듯)


해결방법

먼저 BuildRequestUrlAsync()의 RequestUrlBuilder() 클래스의 함수들을 살펴보았다.
근데 의문인 점은 WithQueries나 다른 메소드에서 settings가 null일 때 Exception을 던지는데 WithSettings 메소드에서는 Setting을 받을 때 Null인지 체크를 안했다.

혹시 Trigger에서 Withsettings() 빼고 다른 메소드만 사용하는 경우가 있나 찾아보니 그것도 아니었다. 그래서 WithSettings()에서 settings가 null일 때의 코드를 추가해주었다.


public RequestUrlBuilder WithSettings<T>(T settings, string endpoint) where T : ToastSettings
        {
			/*내가 추가한 코드 */
            if (this._settings == null)
            {
                this._endpoint ="test";
                throw new InvalidOperationException("Invalid ToastSettings.");
            }
            /*내가 추가한 코드 */

            this._settings = settings;
            this._endpoint = endpoint;

            return this;
        }

그 후 다음과 같이 test 코드를 작성해보았는데...

[TestMethod]
        public void Given_NullSettings_When_Invoke_RequestUrlBuilder_Then_It_Should_Throw_Exception()
        { 
            var set = new Mock<ToastSettings<SmsEndpointSettings>>();
            var result = new RequestUrlBuilder().WithSettings(set.Object, "t"); // Line 212

            var fi = result.GetType().GetField("_endpoint", BindingFlags.NonPublic | BindingFlags.Instance);
            var field = fi.GetValue(result);
            field.Should().Be("t");

        }

오 Line 212에서 InvlalidException이 발생했다는 에러를 발견하였다!!


하지만 settings를 FakeEndpointsSettings로 바꾸고... 이리저리 확인해봐도 아래와 같은 코드는 모든 Exception을 잡았다... func Type은 Task type이고 BuildRequestUrl() 안의 RequestUrlBuilder()를 호출하는 부분(builer)도 null을 반환하는데.. 왜 예외가 잘 안 잡히냔 말이다!!!

[TestMethod]
        // setting이 null일 떄 
        public void Given_NullSettings_When_Invoke_BuildRequestUrl_Then_It_Should_Throw_Exception(){
				var set = new Mock<ToastSettings<SmsEndpointSettings>>();
				var workflow = new HttpTriggerWorkflow(this._factory.Object);

				Func<Task> func = async () => await workflow.BuildRequestUrl("test", set.Object);

				func.Should().ThrowAsync<InvalidOperationException>();
     }

해결방법 2

그래서 걍 예외를 잡는 코드 자체를 바꿔보기로 했다.
참고링크

[TestMethod]
        public void Given_NullSettings_When_Invoke_RequestUrlBuilder_Then_It_Should_Throw_Exception()
        { 
            var set = new Mock<ToastSettings<SmsEndpointSettings>>();
            Assert.ThrowsException<InvalidOperationException>(() => new RequestUrlBuilder().WithSettings(set.Object, "t"));
        }

요렇게 코드를 작성하면 InvalidOperationException에 대해서만 통과한다는 것을 알 수 있다.

혹시나 해서 HttpWorkflow의 BuildRequestUrlAsync()로 잘 잡힐까 확인해봤다.
이게 왠걸... 예외 자체가 아예 발생하지 않음을 알 수 있었다...

이렇게 추가해도 예외자체가 안 발생했다고 뜬ㄷ!!

그래서 settings가 null이 아니냐? 그건 또 아님... 위의 코드를 작성해보면 test는 통과된다.

약간 내 생각엔 예외가 BuildRequestAsync까지 올라가는게 아닌 것 같다.. 걍 예외 처리한 해당 메소드에서 끝나는 듯...?


해결방법 3

테스트 메소드 자체를 비동기 메소드로 바꾸고 다음과 같이 코드를 작성해보았다.

[TestMethod]
        public async Task Given_NullSettings_When_Invoke_BuildRequestUrlAync_Then_It_Should_Throw_Exception()
        { 
            var set = new Mock<ToastSettings<SmsEndpointSettings>>();
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.BuildRequestUrlAsync("Test", set.Object);

            await func.Should().ThrowAsync<InvalidOperationException>();
        }

Quries에 Argument가 null이라는 에러가 뜬 것.. 근데 의문인 점은 WithSettings의 this._settings에 null 체크를 해두었는데 왜 걸리지 않는 것인가..


[TestMethod]
        public async Task Given_NullSettings_When_Invoke_BuildRequestUrlAync_Then_It_Should_Throw_Exception()
        { 
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.BuildRequestUrlAsync("Test", null);

            await func.Should().ThrowAsync<InvalidOperationException>();
        }

아뿔싸... 객체 자체를 null로 바꾸니까 Test가 잘 통과된다.. 즉 Mock 객체로 전달해도 해당 객체에 대한 Reference가 this._settings에 전달되니까 null이 아니었던 것!!!


WithSettings에서 메소드를 다음과 같이 수정해보면 WithSettings에서 setting이 null일 때와 setting안에 아무런 값이 없을 때 예외처리를 할 수 있다. (없으면 setting == null일 때 예외를 WithQueries에서 잡고 setting에 빈 값이 들어가면 그냥 null로 Build하게 된다.)

public RequestUrlBuilder WithSettings<T>(T settings, string endpoint) where T : ToastSettings
        {
            this._settings = settings;
            this._endpoint = endpoint;

            if (this._settings == null || string.IsNullOrWhiteSpace(this._settings.BaseUrl))
            { // Build할 때 BaseUrl이 사용되는 이 필드가 없으면 잘 못된 것  
                throw new InvalidOperationException("Invalid ToastSettings.");
                
            }

            return this;
        }



2. valid한 settings가 들어갔을 때 BuildRequestUrlAsync()하면 기대되는 requestUrl이 나오는지 확인

가짜 Settings를 만들어서 값을 넣고 header랑 가짜 Queries를 만든 후 BuildRequestUrlAsync()를 호출했을 때 기대되는 RequestUrl이 나오는지 확인하는 테스트 코드다.


RequestUrlBuilder()의 Build() 코드를 보고 Test 코드를 생각했다.

[TestMethod]
        public void Given_ValidSettings_When_Invoke_Build_Then_It_Return_requestUrl()
        {
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);
            var settings = new FakeEndpointSettings()
            {
                BaseUrl = "http://localhost:7071/api/{version}/appKeys/{appKey}",
                Version = "v3.0"

            };

            var header = new RequestHeaderModel() { AppKey = "hello", SecretKey = "world" };
            var headers = typeof(HttpTriggerWorkflow).GetField("_headers", BindingFlags.Instance | BindingFlags.NonPublic);
            headers.SetValue(workflow, header);

            var query = new FakeRequestQueries() {};
            var queries = typeof(HttpTriggerWorkflow).GetField("_queries", BindingFlags.Instance | BindingFlags.NonPublic);
            queries.SetValue(workflow, query);

            workflow.BuildRequestUrlAsync("HttpTrigger", settings);
            var fi = workflow.GetType().GetField("_requestUrl", BindingFlags.NonPublic | BindingFlags.Instance);
            var field = fi.GetValue(workflow);

            field.Should().Be("http://localhost:7071/api/v3.0/appKeys/hello/HttpTrigger");
            
        }



3. 그 밖에 추가 test

Settings 객체는 있는데 내부 안의 값이 없는 경우 Null을 반환하는 test는 다음과 같다.

[TestMethod]
        public async Task Given_NoSettings_When_Invoke_BuildRequestUrlAync_Then_It_Should_Throw_Exception()
        { 
            var set = new Mock<ToastSettings<SmsEndpointSettings>>();
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.BuildRequestUrlAsync("Test", set.Object);

            await func.Should().ThrowAsync<InvalidOperationException>();
        }

nul settings랑 비슷하다.


header와 query가 없을 때 build하면 null을 반환 하는 test 코드는 다음과 같다.

[TestMethod]
        public void Given_nullHeader_And_nullQueries_When_Invoke_BuildRequestUrlAsync_Then_It_Return_null()
        {
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);
            var settings = new FakeEndpointSettings()
            {
                BaseUrl = "http://localhost:7071/api/{version}/appKeys/{appKey}",
                Version = "v3.0"

            };

            workflow.BuildRequestUrlAsync("HttpTrigger", settings);
            var fi = workflow.GetType().GetField("_requestUrl", BindingFlags.NonPublic | BindingFlags.Instance);
            var field = fi.GetValue(workflow);

            field.Should().Be(null);
            
        }


✅ Header

1. NullHeader

❌문제발생❌ - Test Code가 잘 안 돌아간다?!

[TestMethod]
        public void Given_NullHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception()
        {
            var req = new Mock<HttpRequest>();
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);

            func.Should().ThrowAsync<NullReferenceException>();
        }

BuildRequestUrlAsync에서 발견한 비슷한 문제!! 저렇게 두면 모든 Exception에 대해서 통과한다. 왜 그럴까.. func 메소드 자체가 비동기 메소드인데 func.Should() 메소드를 통해 바로 예외처리를 판단해버려서 모든 Exception에 대해 통과하는게 아닐까?! 라는 나의 추측


해결방법

[TestMethod]
        public async Task Given_NullHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_ExceptionAsync()
        {
            var req = new Mock<HttpRequest>();
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);

            await func.Should().ThrowAsync<NullReferenceException>();
        }

이렇게 비동기 메소드로 바꾼 후 Test해보면 잘 된다!
왜 그럴까?! 먼저 func 자체가 또 비동기 메소드라 바로 func.Should() 메소드가 호출된다. 이때 await func.Should()는 func에서 예외가 발생할 때까지 대기하게 된다. func 메소드 안의 await workflow.ValiateHeaderAsync은 workflow가 끝날 때까지 대기한다.



❌문제발생❌ - RequestHeaderNotValidException에 대해서 통과가 안 됨!!

즉 여기서 Object reference not set to an instance of an object error가 발생!!1


해결방법

[TestMethod]
        public async Task Given_NullHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_ExceptionAsync()
        {
            var req = new Mock<HttpRequest>();
            var headers = new Mock<HeaderDictionary>();
            req.SetupGet(p => p.Headers).Returns(headers.Object);

            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);

            await func.Should().ThrowAsync<NullReferenceException>();
        }

요렇게 HttpRequest에 가짜 Header를 만들어주고 test 해봤는데...
배열에 원소가 없다는 에러가 뜬다!!! 그 이유는 2번 InvaliHeader 편을 참고!! 어차피 NoHeader랑 비슷해서 걍 지우고 NoHeader의 에러를 InvalidOperationException을 바꿔줌.



2. InvalidHeader

❌문제발생❌ - Exception을 아예 안 던져?!

[DataTestMethod]
        [DataRow("hello", null)]
        [DataRow(null, "world")]
        public void Given_InvalidHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception(string username, string password)
        {
            var bytes = Encoding.UTF8.GetBytes($"{username}:{password}");
            var encoded = Convert.ToBase64String(bytes);

            var headers = new HeaderDictionary();
            headers.Add("Authorization", $"Basic {encoded}");

            var req = new Mock<HttpRequest>();
            req.SetupGet(p => p.Headers).Returns(headers);

            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);

            func.Should().ThrowAsync<RequestHeaderNotValidException>();
        }

위의 코드에서 NullReferenceException으로 바꿔도 통과되길래 비동기 메소드로 바꿔주었는데...


예외를 걍.. 안 던진다는 예외가 뜬다!!



해결방법 - test 해보자!

한번 AppKey랑 SecretKey의 값이 null인지 확인하는 코드를 테스트 했다.

[DataTestMethod]
        [DataRow(null,null)]
        public async Task Given_InvalidHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception(string username, string password)
        {
            var bytes = Encoding.UTF8.GetBytes($"{username}:{password}");
            var encoded = Convert.ToBase64String(bytes);

            var headers = new HeaderDictionary();
            headers.Add("Authorization", $"Basic {encoded}");

            var req = new Mock<HttpRequest>();
            req.SetupGet(p => p.Headers).Returns(headers);

            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            var result = await workflow.ValidateHeaderAsync(req.Object);
            var fi = workflow.GetType().GetField("_headers", BindingFlags.NonPublic | BindingFlags.Instance);
            var field = fi.GetValue(result) as RequestHeaderModel;

            field.Should().NotBeNull();
            field.AppKey.IsNullOrWhiteSpace().Should().Be(true);
            field.SecretKey.IsNullOrWhiteSpace().Should().Be(true);
        }
  • null, null인 경우

    걍 에러가 뜬다..........

  • "hello", null / null, "world"인 경우
    뭐야 SecretKey null인데 잘 못 잡는다는 것을 확인하였다.

그래서 어떤 값을 받아오는지 확인했는데 (null, world로 데이터를 전달)
띠용... AppKey는 분명 null이어야 하는데??

그래서 혹시나 하고 AppKey가 "world"가 나온다고 하고 test 해보니
통과 된다...



해결방법2 - 문제를 찾았다!

생각해보니 데이터를 null과 "world"를 받았을 때 values에는 배열이 생성되는데 world만 배열에 포함되어 크기가 1인 배열이 생성된다. 따라서 values.Fist(), values.Last() 모두 world가 되는 것!!

따라서 null 혹은 "" 대신 " "가 들어가야 테스트가 정상적으로 통과된다!

[DataTestMethod]
        [DataRow("hello", " ")]
        [DataRow(" ", "world")]
        public async Task Given_InvalidHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception(string username, string password)
        {
            var bytes = Encoding.UTF8.GetBytes($"{username}:{password}");
            var encoded = Convert.ToBase64String(bytes);

            var headers = new HeaderDictionary();
            headers.Add("Authorization", $"Basic {encoded}");

            var req = new Mock<HttpRequest>();
            req.SetupGet(p => p.Headers).Returns(headers);

            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);

            await func.Should().ThrowAsync<RequestHeaderNotValidException>();
        }

코드를 분석해보니
StringSplitOptions.RemoveEmptyEntries 이 옵션은 결과의 빈 문자열을 포함하는 배열 요소를 생략하기 때문에 null이나 ""이 들어가면 배열이 생성이 안 된 다는 것을 알 수 있었다.


멋지게(?) 해결하고 pr 날렸음! 링크를 확인하도록!!



실제 공식 깃허브에 직접 PR을 날렸다(사실 PR 쪼개기 목적)! 바로 accept 되었댜

3. GetMessage

GetMessage 엔드포인트의 catch문에서 RequestHeaderNotValidException 와RequestQueryNotValidException을 ToastException으로 통합한 것에 대한 테스트 코드 작성해보는 것을 추가해야 한다.


  • pr 쪼개기 즉,, pr 보낼 때 한번에 많은 파일을 수정하는게 아니라 파일 2개 정도면 충분!! 즉 만약 코드를 건드리다 새로운 파일을 만들었어 그러면 그 새로운 파일에 대한 테스크 코드가 포함되어 있어야 함!!
  • model은 필요없고 GetMessage ㅔ 대한 test, Extension test, Validate라는 메소드를 테스트 하기 위해서!!
  • 문제가 생기면 제발 바로바로 issue에 물어봐라!!! 모르는게 있으면 능동적으로!!!! 방향이 맞는지 확인 부탁!!!


✅ PR 날릴 때 왜 브랜치를 새로 만들까?

헤더 부분에 대한 PR을 날리고 RequestUrlBuilder 부분에 대한 PR을 추가로 날리고 싶은 상황! 그래서 RequestUrlBuilder에 대한 내용을 수정하고 내 로컬 레포에서 commit하고 push했는데..?

어랍쇼??? pr을 날리지도 않았는데 기존에 내가 보냈던 헤더 부분 PR에 commit이 추가로 쌓이는 게 아닌가??


당황한 나머지 revert(hard)를 통해 헤더 부분 pr을 보낼 때 상태로 되돌려 놓았다....


왜 그럴까.. 그동안 나는 PR을 보내기 위한 branch를 따로 생성하지 않고 내 로컬의 main branch에 변경사항을 push하고 fork한 레포에 PR을 바로 보냈고 merge하기 전에 commit을 한 적이 없어서 몰랐던 것..!!

Merge가 안 된 상태에서 PR을 보낸 branch에서 그대로 commit하면 pr에도 commit이 쌓있다는 것!!!


그래서 PR 날리는 법을 찾아볼 때 왜 귀찮게 브랜치를 만들고 삭제하는 거지!? 하고 의문이 들었는데.. 역시.. 다 계획이 있었던 것!!
profile
SW Engineer 꿈나무 / 자의식이 있는 컴퓨터

0개의 댓글