JUnit 4 테스트 작성 (3) - MultipartFile 업로드 동작 테스트

gentledot·2021년 4월 11일
0

테스트와 로깅

목록 보기
4/5
post-thumbnail

thumbnail 출처 : https://unsplash.com/photos/9IDgEybpF6o?utm_source=63921&utm_medium=referral

개요

  • 테스트 환경에서 단위테스트를 구현하는 방식에 대해 정리하고자 포스트를 작성하였습니다.
  • 정보를 찾아 테스트를 작성하였지만 작성된 단위테스트에 대한 피드백을 받을 수 없는 환경이기 때문에 부정확한 정보가 정리되었을 수 있습니다.
  • 테스트 환경은 이전 포스트에 정리하였습니다.

    잘못된 내용이나 더 나은 방식 등에 대한 의견은 댓글로 피드백 부탁드리겠습니다. :)

MultipartFile

  • MultipartFile은 스프링에서 업로드를 위해 제공되는 interface입니다.
    A representation of an uploaded file received in a multipart request.
    The file contents are either stored in memory or temporarily on disk. In either case, the user is responsible for copying file contents to a session-level or persistent store as and if desired. The temporary storages will be cleared at the end of request processing.
    Since:
    29.09.2003
    See Also:
    MultipartHttpServletRequest, MultipartResolver
    Author:
    Juergen Hoeller, Trevor D. Cook

MultipartFile 테스트 사례 - POI 활용 엑셀업로드 기능

  • 경험했던 작업 중에서 해당 interface를 통해 엑셀 파일을 업로드 받고, 데이터 처리를 진행하는 로직에 대한 수정이 필요했던 적이 있었습니다.
    • 해당 로직은 크게 3가지의 구간으로 구성되어 있었습니다.
      • 엑셀 파일 업로드 및 업로드 파일에서 데이터 산출
      • 산출 데이터 상에서 필요 데이터 확인 및 데이터 저장
      • 데이터 처리 결과에 대한 return, 실패 건에 대한 메시지 출력
    • 해당 기능에 수정 사유가 발생할 경우 정확한 원인을 파악하기 위해서는 구간 전체를 흘려보며 debugging을 하는 방법만 가능하였습니다.

MultipartFile 사용 로직의 단위 테스트

  • 해당 기능을 확인하고자 로컬 내 서버를 기동하고, 프로그램을 실행하고, 로그인하고, 메뉴 접근하고, 엑셀업로드 기능을 실행하고, 디버깅 확인하고, 결과 확인하고......
  • 해당 기능을 작업하는데 단위 테스트를 작성하고자 했던 이유는
    1. 각 구간이 정상적으로 동작하는지에 대한 보장이 있어야 함. (순차적으로 동작하는 기능에서 이전 기능의 정상 동작이 확인되면 다음 동작 구간을 바로 확인할 수 있음)
    2. debugging을 하기 까지의 시간이 오래걸리기 때문에. 더욱이 breakpoint를 걸어둔다 하더라도 한 번 흐른 과정에서 다른 구간의 문제를 확인하려면 다시 업로드 기능을 실행하여 debugging 해야 확인 가능함.
    3. 앞으로의 수정 사항이나 보완할 점이 생긱는 경우 기존 기능에 대한 정상 동작에 대한 보증이 필요하다.
  • 아래의 내용은 엑셀 파일 업로드 및 업로드 파일에서 데이터 산출 로직 구간에서 정상 엑셀 파일을 검증하는 부분에 대해 정리하였습니다.

관련 라이브러리 (의존성) 설정

  • Apache POI 라이브러리 의존성 설정

    <!-- poi excel 라이브러리 -->
    <dependency>
      <groupId>org.apache.poi</groupId>
      <artifactId>poi</artifactId>
      <version>3.14</version>
    </dependency>
    
    <dependency>
      <groupId>org.apache.poi</groupId>
      <artifactId>poi-ooxml</artifactId>
      <version>3.14</version>
    </dependency>
  • Multipart request 처리 (commons-io, commons-fileupload)

    <!-- MultipartHttpServletRequest -->
    <dependency>
      <groupId>commons-io</groupId>
      <artifactId>commons-io</artifactId>
      <version>2.4</version>
    </dependency>
    
    <dependency>
      <groupId>commons-fileupload</groupId>
      <artifactId>commons-fileupload</artifactId>
      <version>1.3.1</version>
    </dependency>

MockMultipartFile 생성 테스트

  • 화면 내에서 전달받은 request의 Multipart를 대체하기 위해 MockMultipartFile로 stubbing 할 수 있습니다. (request로 multipart를 정상적으로 전달되었다고 가정)

    @Test
    @TestDesc("MockMultipartFile 동작 테스트")
    public void getMockExcelUploadTest() throws IOException {
          /*MockMultipartHttpServletRequest multipartHttpServletRequest = new MockMultipartHttpServletRequest();*/ // controller test 시 사용
          String fileName = "testCustomerUpload";
          String contentType = "xls";
          String filePath = "src/test/resources/excel/testCustomerUpload.xls";
          MockMultipartFile mockMultipartFile = getMockMultipartFile(fileName, contentType, filePath);
    
          String getFileName = mockMultipartFile.getOriginalFilename().toLowerCase();
    
          assertThat(getFileName, is(fileName.toLowerCase() + "." + contentType));
      }
      
    private MockMultipartFile getMockMultipartFile(String fileName, String contentType, String path) throws IOException {
          FileInputStream fileInputStream = new FileInputStream(new File(path));
          return new MockMultipartFile(fileName, fileName + "." + contentType, contentType, fileInputStream);
      }
    • getMockMultipartFile() method를 구성하여 필요한 mock을 생성하는 부분을 별도로 빼서 구성하였습니다.
    • MockMultipartFile 을 생성하는 다른 방식에 대해서는 다음의 링크에서 확인할 수 있었습니다. Converting File to MultiPartFile - stackoverflow

읽어들인 Excel 파일의 정보 확인

  • 해당 테스트는 multipart를 통해 excel 파일을 전달 받았을 때 받은 파일을 POI를 통해 excel workbook으로 확인하고 관련 정보를 확인하는 내용입니다.

    @Test
    @TestDesc("엑셀 정보 가져오기 확인")
    public void getExcelSettingInfo() throws IOException {
            // arrange
            ExcelUploadVO testUploadVO = getTestUploadVO();
    
            String fileName = "testCustomerUpload";
            String contentType = "xls";
            String filePath = "src/test/resources/excel/testCustomerUpload.xls";
            MockMultipartFile mFile = getMockMultipartFile(fileName, contentType, filePath);
    
            // act
            Map<String, Object> resultMap = new HashMap<String, Object>();
            boolean error = false; // 정상 엑셀파일이 아닌 경우 true
            String ret_msg = null; // 확인 사유를 기록하는 변수
    
            // 엑셀파일 설정정보
            int startCellNum = testUploadVO.getStartCellNum(); // 시작할 셀
            int totalCellNum = testUploadVO.getTotalCellNum(); // 총 셀수
            int sheetNum = testUploadVO.getSheetNum(); // 조회할 시트번호
            int startRowNum = testUploadVO.getStartRowNum(); // 시작하는 기준 행 번호
    
            String filename = mFile.getOriginalFilename().toLowerCase(); // 파일명
            int indexDot = filename.lastIndexOf(".") != -1 ? filename.lastIndexOf(".") : 0;
            String fileExtention = filename.substring(indexDot); // 확장자
    
            Workbook workbook = null;
    
            if (fileExtention.equals(".xlsx")) {
                workbook = new XSSFWorkbook(mFile.getInputStream());// Excel 2007
            } else if (fileExtention.equals(".xls")) {
                workbook = new HSSFWorkbook(mFile.getInputStream());// Excel 2003
            }
    
            // workbook 생성 여부 체크
            if (workbook == null || workbook.getNumberOfSheets() == 0) {
                error = true;
                ret_msg= "엑셀파일에 시트가 존재하지 않습니다.";
    
                resultMap.put("error",  error);
                resultMap.put("ret_msg", ret_msg);
            }
    
            Sheet sheet = workbook.getSheetAt(sheetNum); // 조회할 시트
    
            int lastRowNum = sheet.getLastRowNum();
            if(lastRowNum ==(startRowNum-1)) {
                error = true;
                ret_msg= "입력된 데이터가 존재하지 않습니다.";
    
                resultMap.put("error",  error);
                resultMap.put("ret_msg", ret_msg);
    //			return resultMap;
    
            } else if((lastRowNum - startRowNum+1)>500) {
                error = true;
                ret_msg= "입력건수가 500건을 초과하였습니다.";
    
                resultMap.put("error",  error);
                resultMap.put("ret_msg", ret_msg);
    //			return resultMap;
            }
    
            // 엑셀 양식 확인
            // 정해진 시작 열과 마지막 열이 아닌 경우 확인
            Row columRow = sheet.getRow(startRowNum-1);
            String startColumnName = testUploadVO.getStartColumnName();
            String lastColumnName = testUploadVO.getLastColumnName();
    
            boolean excelFormCheck = true;
            if (!StringUtils.isEmpty(startColumnName) && !StringUtils.isEmpty(lastColumnName)
                    && columRow.getCell(startCellNum) != null && columRow.getCell(totalCellNum) != null) {
    
                if(!startColumnName.equals(columRow.getCell(startCellNum).getStringCellValue()) || !lastColumnName.equals(columRow.getCell(totalCellNum).getStringCellValue())) {
                    excelFormCheck = false;
                }
    
            } else {
                excelFormCheck = false;
            }
    
            if(!excelFormCheck){
                error = true;
                ret_msg= "잘못된 엑셀양식 입니다.";
                resultMap.put("error",  error);
                resultMap.put("ret_msg", ret_msg);
    //			return resultMap;
            }
    
            // assert
            assertThat(resultMap.isEmpty(), is(true));
            assertThat(lastRowNum, is(98));
            assertThat(lastRowNum == (startRowNum - 1), is(false));
            assertThat((lastRowNum - startRowNum + 1), is(98));
            assertThat(columRow.getCell(startCellNum), is(notNullValue()));
            assertThat(columRow.getCell(totalCellNum), is(notNullValue()));
            assertThat(startColumnName, is("계정코드")); // 업로드 엑셀 파일의 첫 셀의 title
            assertThat(lastColumnName, is("구분")); // 업로드 엑셀 파일의 마지막 셀의 title
            assertThat(startColumnName.equals(columRow.getCell(startCellNum).getStringCellValue()), is(true));
            assertThat(lastColumnName.equals(columRow.getCell(totalCellNum).getStringCellValue()), is(true));
            assertThat(excelFormCheck, is(true));
      }
    • (POI 3.14 기준) 엑셀 버전에 따라 다르게 처리됩니다.

      • 2007+ 은 XSSFWorkbook
      • 2003 이하는 HSSFWorkbook
      Workbook workbook = null;
      
      if (fileExtention.equals(".xlsx")) {
              workbook = new XSSFWorkbook(mFile.getInputStream());// Excel 2007
      } else if (fileExtention.equals(".xls")) {
              workbook = new HSSFWorkbook(mFile.getInputStream());// Excel 2003
      }
    • 해당 로직에서 개선 욕심을 가진다면 다음의 부분을 수정할 것 같습니다.

      1. 정상 엑셀 파일로 확인되지 않는 경우에 return value를 입력한 뒤 Exception을 발생하여 로직을 빠져나오도록 구성
      2. if 분기로 확인 중인 엑셀파일 확인 로직을 묶어 하나의 method로 추출
      3. (startRowNum - 1), (startRowNum + 1) 값을 좀 더 이해가 용이한 변수로 변환

확인사항

POI에서 엑셀 객체를 다루는 model

  • POI를 통한 엑셀 파일은 다음의 model로 확인합니다.
    • workbook = org.apache.poi.ss.usermodel.Workbook
      • new XSSFWorkbook(java.io.InputStream is)
    • worksheet = org.apache.poi.ss.usermodel.Sheet
      • workbook.getSheetAt(int index);
    • row = org.apache.poi.ss.usermodel.Row
      • sheet.getRow(int rownum)
    • cell = org.apache.poi.ss.usermodel.Cell
      • row.getCell(int cellnum)

POI에서의 CellValue 구분

  • (POI 3.14 기준) cell의 값은 cellType에 따라 값을 달리 가져와야 합니다.
    • Cell.CELL_TYPE_STRING (1)
      • cell.getStringCellValue() 로 값 처리
    • Cell.CELL_TYPE_NUMERIC (0)
      • HSSFDateUtil.isCellDateFormatted(cell)로 숫자값이 날짜를 출력하는 값인지 확인 후 적절한 날짜 형식으로 변환하여 처리
      • 숫자는 cell.getNumericCellValue() 로 값 처리
    • Cell.CELL_TYPE_BLANK (3)
      • cell.getBooleanCellValue() 로 값을 확인
        • 빈 셀은 false
    • Cell.CELL_TYPE_FORMULA (2)
      • cell.getCachedFormulaResultType() 으로 CELL_TYPE_NUMERIC, CELL_TYPE_STRING, CELL_TYPE_BOOLEAN 인지 판별한 후 값을 처리
      • 위의 값들로 판별되지 않는다면 cell.getCellFormula() 로 값 처리
    • Cell.CELL_TYPE_ERROR (5)
      • cell.getErrorCellValue() 로 값을 확인
        • 빈 값은 0 으로 확인됨
      • 오류 값은 FormulaError 라는 enum에서 확인할 수 있음.
        • field는 byte type, (int longType), String repr로 구성
      • 오류 값 유형
        • NULL(0x00, "#NULL!")
        • DIV0(0x07, "#DIV/0!")
        • VALUE(0x0F, "#VALUE!")
        • REF(0x17, "#REF!")
        • NAME(0x1D, "#NAME?")
        • NUM(0x24, "#NUM!")
        • NA(0x2A, "#N/A")
        • POI-specific error codes
          • CIRCULAR_REF(0xFFFFFFC4, "~CIRCULAR~REF~")
          • FUNCTION_NOT_IMPLEMENTED(0xFFFFFFE2, "~FUNCTION~NOT~IMPLEMENTED~")
profile
그동안 마신 커피와 개발 지식, 경험을 기록하는 공간

0개의 댓글