개발 조각글

Unity Refactoring - Reflection을 이용한 유닛 테스트

BaekNohing 2022. 8. 18. 22:45

a cute cat snail

달팽이 키우기 리팩토링 목차

  1. 리팩토링 준비
  2. 구조 재설계
  3. UI Manager 설계
  4. 클래스간 관계
  5. Action 분리하기
  6. 핵심루프 설계
  7. 유닛 테스트
달팽이 키우기 유닛 테스트 요약
Reflection을 이용하면, 클래스의 private로 선언된 값과 함수들도 가져올 수 있다. 
이것을 이용해, 클래스 내부에 테스트용 함수를 따로 만들지 않아도 테스트를 진행할 수 있다. 

달팽이 테스트 UIManager 테스트

설계와 함께 테스트를 작성해볼까? 생각해봤었지만. 달팽이키우기 리팩토링은 1주 이내, 실 작업시간 24시간 이내로 잡고 진행한 프로젝트였기 때문에. 학습비용까지 계산했을 때 견적이 나오질 않을 것 같아서 일단 설계를 끝내고, 이후에 테스터를 붙이는 쪽으로 가닥을 잡았다. 

테스터를 붙일 때 한가지 난관에 봉착했는데, 캡슐화때문에 아래와 같이 변수들이 private한 상태로 숨겨져 있었다는 것이다! 테스트 코드도 하나의 클래스기 때문에, 일반적인 사용법만으로는 (그러니까 UIManager uiManager = new UIManager(); 따위의 방법만으로는) UIManager내부의 mainCanvas나 uiPanels등의 값들에 접근할 수 없었다. (그리고 제대로 실행되었는지 검증할  수 없었다.)


이를 위해서 "유닛 테스트를 위한 함수 설계" 와 같은 형태로 함수를 바꾸는 방법도 생각해 봤지만. 다른 방법이 있지 않을까? 고민했고, C#의 Reflction이라는걸 찾아냈다. 

Contains types that retrieve information about assemblies, modules, members, parameters, and other entities in managed code by examining their metadata.

Reflection은 MS DotNet 6.0문서에 따르면, 메타데이터에 존재하는 멤버나 파라메터와 같은 것들을 코드를 이용해 검색할 수 있는 것이라고 한다. 즉 런타임에서 동적으로 클래스 내부의 변수나 함수를 찾을 수 있는 것으로 이해했다. 

즉 메타데이터에 있는 오브젝트의 타입 설계도를 읽고 그 안에서 후술할 조건에 일치하는 대상을 찾을 수 있다는 뜻이다! 심지어 이 설계도에는 private로 선언된 정보도 들어있기 때문에 그 값도 조건으로 찾아낼 수 있다!

 FieldInfo[] Fields = uiManager.GetType()
 	.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
    //너무 길기 때문에 개행함.

다시말해 이런식으로 선언하면, 여기에 mainCanvas나 uiPanels와 같이 private로 선언되어서, 외부에서 읽을 수 없었던 변수들의 정보를 담아낼 수 있게 되는 것이다. 하지만 여기에 담겨져있는건 단지 해당 변수에 대한 정보뿐이기 때문에. 선언된 클래스의 값에 접근하기 위해서는 실체를 가지고 있는 오브젝트가 필요하다. (즉 설계도와, 오브젝트가 모두 있어야 그 안의 값(!)을 얻어올 수 있다.)

//obj는 object형식, fieldName은 string
FieldInfo fieldInfo = 
	obj.GetType()
    .GetField(fieldName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);

fieldInfo.GetValue(obj);

즉 아까 얻었던 fieldInfo에 다시 .GetValue(obj)를 붙여서 값을 얻어오면 된다! Reflection을 이용해서 필드에 접근하면, 성능면에서 손해를 본다고는 하지만. 테스트유닛에서 간단하게 숨겨진 변수를 가져올 수 있는 이점이 살짝 성능면에서 테스트코드가 손해보는 것 보다 훨씬 크다고 판단했다. 

이렇게 UIManager의 숨겨진 함수와 변수에 테스트유닛을 붙일 수 있었고, 같은 방식으로 유닛 테스트의 테스트 커버리지를 높여갈 예정이다.  


 

using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using System.Linq;
using System.Reflection;
using UnityEngine.SceneManagement;

public class Test_UIManager
{
    UIManager uiManager;
    Dictionary<string, MethodInfo> methodsDic = new Dictionary<string, MethodInfo>();
    
    [SetUp]
    public void SetUp()
    {
        //SceneManager.LoadScene("MainScene");
        uiManager = GameObject.Find("Managers").GetComponent<UIManager>();
        var Methods = uiManager.GetType().GetMethods(
            BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
        methodsDic.Clear();
        foreach(var method in Methods)
            if(!methodsDic.ContainsKey(method.Name))
                methodsDic.Add(method.Name, method);
        var Fields = uiManager.GetType().GetFields(
            BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
                
        Assert.IsNotNull(uiManager);
        Assert.IsNotEmpty(methodsDic);
    }

    [Test]
    public void Test_FindObjects()
    {
        foreach(var method in methodsDic)
            if (method.Key == "SetObjects")
                method.Value.Invoke(uiManager, null);

        Assert.IsNotNull(TestUtils.GetField(uiManager, "mainCanvas"));
        Assert.IsNotNull(TestUtils.GetField(uiManager, "actionManager"));
        Assert.IsNotEmpty(TestUtils.GetField(uiManager, "uiPanels") as List<UIPanels>);
        Assert.IsNotEmpty(TestUtils.GetField(uiManager, "btnList") as List<SelfManageButton>);
    }
    ...
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Reflection;

public static class TestUtils 
{
    public static bool SrcHasDest(string src, string dest)
    {
        if (src == null || dest == null)
            return false;
        return src.ToLower().Contains(dest.ToLower());
    }

    public static object GetField(this object obj, string fieldName)
    {
        return obj.GetType().GetField(fieldName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).GetValue(obj);
    }
}