Vite 프로젝트를 설정하고, TDD를 통해 테스트 주도 개발 하며 캘린더를 구현해본다.
애플리케이션의 코드를 작성하기 전에 테스트를 작성하고 이를 통과시키면서 코드를 개발하는 접근 방식이다.
Vite와 TDD 를 함께 사용하면서 애플리케이션의 안정성을 향상시키고 유지보수를 용이하게 만들 수 있다.
src > components > __tests__
폴더 내 _test_
폴더를 만들어 __.test.js
파일을 통해 테스트를 수행할 수 있다.
LocalDate 클래스는 날짜 및 시간의 offset 을 계산하도록 하는 기능이다.
1. contructor(value)
: Date 객체를 받아 LocalDate 인스턴스를 생성하여 주어진 Date 객체를 클래스의 value 프로퍼티에 할당
2. year, month, date Getter 메서드
: value 프로퍼티에 연도, 월, 일을 추출 / pad 함수를 이용하여 연도를 4자리로, 월과 일을 각각 2자리로 포맷
3. ymdText Getter 메서드
: year, month, date 를 사용하여 날짜를 'yyyy-mm-dd'형식의 문자열로 반환
4. weekOffset Getter 메서드
: value 의 요일을 반환 (0: 일요일, 1: 월요일, ..., 6: 토요일)
5. minus(delta, unit), plus(delta, unit) 메서드
: delta와 unit 인수를 받아 날짜를 더하거나 빼는 메서드로 shiftDate 함수를 사용
6. equals(other) 메서드
: 다른 LocalDate 객체와 비교하여 두 객체의 연도, 월, 일이 같은지에 대한 여부 확인
//index.js
/**
*
* @param {number} num
* @param {number} length
*/
const pad = (num, length) => {
return ('0000' + num).slice(-length)
}
const MILLIS = {
day: 1 * 24 * 60 * 60 * 1000,
week: 7 * 1 * 24 * 60 * 60 * 1000
}
/**
*
* @param {LocalDate} date
* @param {number} delta
* @param {string} unit
*/
const shiftDate = (date, delta, unit) => {
const millis = delta * MILLIS[unit]
const curMillis = date.value.getTime()
return new LocalDate(new Date(curMillis + millis))
}
export class LocalDate {
/**
* @type {Date} date instance
*/
value
/**
*
* @param {Date} value
*/
constructor(value) {
this.value = value
}
get year() {
return pad(this.value.getFullYear(), 4) //It is number
}
get month() {
return pad(this.value.getMonth() + 1, 2) //number
}
get date() {
return pad(this.value.getDate(), 2) //number
}
get ymdText() {
const { year, month, date } = this
return `${year}-${month}-${date}`
}
get weekOffset() {
return this.value.getDay()
}
/**
*
* @param {number} delta
* @param {string} unit - 'day', 'week', ..
*/
minus(delta, unit) {
return shiftDate(this, -1 * delta, unit)
}
plus(delta, unit) {
return shiftDate(this, delta, unit)
}
/**
*
* @param {LocalDate} date
*/
equals(other) {
return (
other && other.year === this.year && other.month === this.month && other.date === this.date
)
}
}
/**
*
* @param {Number} year
* @param {Number} month 1 ~ 12(not monthIndex)
* @param {*} date 1 ~ 28, 30, 31
* @returns
*/
LocalDate.fromYmd = (year, month, date) => {
const value = new Date(year, month - 1, date)
return new LocalDate(value)
}
//LocalDate.test.js
import { describe, test, expect } from 'vitest'
import { LocalDate } from '..'
describe('class LocalDate', () => {
test('format date', () => {
const d0 = LocalDate.fromYmd(2023, 6, 9)
expect(d0.year).toBe('2023')
expect(d0.month).toBe('06')
expect(d0.date).toBe('09')
expect(d0.ymdText).toBe('2023-06-09')
})
test('week offset of a date', () => {
/**
* 2023 - 06
*
* S M T W T F Sat
* 27 5
* 31 01 02 3 5,6
* 4 5 6 7 8 9 10 6
* 11 17 6
* 18 24 6
* 25 30 01 6,7
* ---------------
* 0 1 2 3 4 5 6 offset in a week (sunday: 0, saturday: 6)
*/
const d0 = LocalDate.fromYmd(2023, 6, 9)
expect(d0.weekOffset).toBe(5)
})
test('minus', () => {
const fri = LocalDate.fromYmd(2023, 6, 9)
const thu = fri.minus(1, 'day')
expect(thu.date).toBe('08')
expect(thu).toStrictEqual(LocalDate.fromYmd(2023, 6, 8))
expect(fri.minus(7, 'day')).toStrictEqual(LocalDate.fromYmd(2023, 6, 2))
})
test('plus', () => {
const fri = LocalDate.fromYmd(2023, 6, 9)
expect(fri.plus(7, 'day')).toStrictEqual(LocalDate.fromYmd(2023, 6, 16))
})
})
LocalDate.test.js
파일을 통해 테스트를 진행한다.
Week 클래스 는 특정 주(Week)의 정보를 다룬다.
1. contructor(leadingDate)
: 특정 주의 시작 날짜(leadingDate)를 받아 Week 인스턴스 생성
: leadingDate 는 주의 첫번째 날짜로 사용되며 date 프로퍼티에 할당
2. dayAt(weekOffset) 메서드
: 특정 주의 weekOffset 에 해당하는 날짜를 반환 , 0부터 6까지의 값(0: 일요일, 6: 토요일)
3. days() 메서드
: 현재 주에 속한 7일간의 날짜를 배열로 반환, dayAt 메서드를 사용하여 7일간의 날짜를 구함
4. prev(), next() 메서드
: 이전 주와 다음 주의 Week 인스턴스 반환
export class Week {
/**
* @type {LocalDate} leading date (sunday ?)
*/
day
/**
*
* @param {LocalDate} leadingDate
*/
constructor(leadingDate) {
this.date = leadingDate
}
/**
*
* @param {number} weekOffset 0(sunday), 6(saturday)
* @returns
*/
dayAt(weekOffset) {
return this.date.plus(weekOffset, 'day')
}
/**
*
* @returns days of this week
*/
days() {
const sevenDays = []
for (let offset = 0; offset < 7; offset++) {
const day = this.date.plus(offset, 'day')
sevenDays.push(day)
}
return sevenDays
}
prev() {
const leadingDate = this.date.minus(7, 'day')
return new Week(leadingDate)
}
next() {
const leadingDate = this.date.plus(7, 'day')
return new Week(leadingDate)
}
}
/**
*
* @param {LocalDate} date
* @returns
*/
Week.fromLocalDate = (date) => {
const offset = date.weekOffset //현재 날짜의 요일을 구함
const sunday = date.minus(offset, 'day') //offset 만큼 minus -> 일요일은 0이므로
return new Week(sunday)
}
//Week.test.js
import { describe, test, expect } from 'vitest'
import { LocalDate, Week } from '..'
describe('class Week', () => {
test('capturing week range from a date', () => {
/**
* 2023 - 06
*
* S M T W T F Sat
* 27 5
* 28 29 30 31 01 02 3 5,6
* 4 5 6 7 8 9 10 6
* 11 17 6
* 18 24 6
* 25 30 01 6,7
* ---------------
* 0 1 2 3 4 5 6 offset in a week (sunday: 0, saturday: 6)
*/
const d7 = LocalDate.fromYmd(2023, 6, 7) //[4, ..., 10]
const w0 = Week.fromLocalDate(d7)
expect(w0.dayAt(0)).toStrictEqual(LocalDate.fromYmd(2023, 6, 4))
expect(w0.dayAt(6)).toStrictEqual(LocalDate.fromYmd(2023, 6, 10))
})
test('prev next week', () => {
const d7 = LocalDate.fromYmd(2023, 6, 7) //[4, ..., 10]
const w0 = Week.fromLocalDate(d7)
const prevWeek = w0.prev()
expect(prevWeek.dayAt(0)).toStrictEqual(LocalDate.fromYmd(2023, 5, 28))
const nextWeek = w0.next()
expect(nextWeek.dayAt(0)).toStrictEqual(LocalDate.fromYmd(2023, 6, 11))
})
})
Week.test.js
파일을 통해 테스트를 진행한다.
Calendar 클래스는 월 단위로 주(Week)를 관리하는 클래스로 특정 월에 해당하는 주들을 포함하고, 해당 월의 첫 주와 마지막 주, 년도 및 월 정보를 제공한다.
1. constructor(weeks)
: Week 인스턴스들(특정 월에 해당하는 주)의 배열을 받아 Calendar 인스턴스 생성
2. firstWeek, lastWeek Getter 메서드
: 첫번째 주와 마지막 주에 해당하는 Week 인스턴스 반환
3. yearText, monthText Getter 메서드
: firstWeek 를 기준으로 해당 월의 년도와 월을 텍스트 형식으로 반환
4. getWeeks() 메서드
: 해당 Calendar 인스턴스에 속한 모든 주(Weeks)를 반환
5. prevMonth(), nextMonth() 메서드
: 이전 월 또는 다음 월에 해당하는 Calendar 인스턴스 반환
: firstWeek.prev() 또는 lastWeek.next() 를 사용하여 계산
6. contiansDate(date) 메서드
: 주어진 날짜(date)가 현재 Calendar 에 속하는지에 대한 여부 확인
: 쉽게 말해 날짜의 월이 현재 월과 동일한지를 체크하여 판단
7. isToday(date) 메서드
: 주어진 날짜(date)가 현재 날짜인지 확인, LocalDate 클래스의 equals 메서드를 사용하여 현재 날짜와 비교
Calendar.fromYm
: 년도와 월을 받아 해당 월에 대한 Calendar 인스턴스 생성
: 해당 월의 첫 날에 대한 LocalDate 와 Week 인스턴스를 생성하고, 이를 기반으로 6주간의 Week 인스턴스를 배열에 추가하여 Calendar 를 생성
export class Calendar {
/**
* @type {Week[]} weeks of a month
*/
weeks
/**
*
* @param {Week[]} weeks
*/
constructor(weeks) {
this.weeks = weeks
}
get firstWeek() {
return this.weeks[0]
}
get lastWeek() {
return this.weeks[this.weeks.length - 1]
}
get yearText() {
return this.firstWeek.dayAt(0).year
}
get monthText() {
return this.firstWeek.dayAt(6).month //check last dat of a week
}
getWeeks() {
return this.weeks
}
prevMonth() {
// any day of prev month
const anyDay = this.firstWeek.prev().dayAt(0)
const year = Number.parseInt(anyDay.year) // "2023" -> 2023
const month = Number.parseInt(anyDay.month)
return Calendar.fromYm(year, month)
}
nextMonth() {
// any day of next month
const anyDay = this.lastWeek.next().dayAt(0)
const year = Number.parseInt(anyDay.year) // "2023" -> 2023
const month = Number.parseInt(anyDay.month)
return Calendar.fromYm(year, month)
}
/**
*
* @param {LocalDate} date
*/
containsDate(date) {
return this.monthText === date.month
}
/**
*
* @param {LocalDate} date
*/
isToday(date) {
const now = new LocalDate(new Date())
return now.equals(date)
}
}
/**
*
* @param {number} year
* @param {number} month 1 ~ 12(not month index)
* @returns
*/
Calendar.fromYm = (year, month) => {
const leadingDate = LocalDate.fromYmd(year, month, 1)
const leadingWeek = Week.fromLocalDate(leadingDate)
const weeks = []
let ref = leadingWeek
while (weeks.length < 6) {
weeks.push(ref)
ref = ref.next()
}
return new Calendar(weeks)
}
//Calendar.test.js
import { describe, test, expect } from 'vitest'
import { Calendar } from '..'
describe('class Calendar', () => {
test('init', () => {
const m6 = Calendar.fromYm(2023, 6)
expect(m6.yearText).toBe('2023')
expect(m6.monthText).toBe('06')
const weeks = m6.getWeeks()
expect(weeks.length).toBe(6)
})
test('prev next month', () => {
const m01 = Calendar.fromYm(2023, 1)
const m12 = m01.prevMonth()
expect(m12.yearText).toBe('2022')
expect(m12.monthText).toBe('12')
const m02 = m01.nextMonth()
expect(m02.yearText).toBe('2023')
expect(m02.monthText).toBe('02')
})
})
Calendar.test.js
파일을 통해 테스트를 진행한다.
npm run test:unit
명령어를 통해 프로젝트에서 유닛 테스트를 실행한다.
"scripts": {
"test:unit": "vitest",
}
package.json 파일에서 scripts 섹션을 확인하여 구체적인 설정을 찾아볼 수 있는데, test:unit 명령어가 vitest 이므로 npm run vitest
인 것이나 다름없다.
LocalDate.test.js
에서 4가지 테스트와
Week.test.js
에서 2가지 테스트,
Calendar.test.js
에서 2가지 테스트로
총 3개의 파일과 8개의 테스트가 성공적으로 완료되었다.
MonthCalendar.vue
컴포넌트를 통해 생성한 클래스들을 이용해 달력을 구현한다.
<template>
<div class="calendar">
<nav>
<h3>
<span>{{ cal.yearText }} - {{ cal.monthText }}</span>
</h3>
<div class="navs">
<button @click="prevMonth()">prev</button>
<button @click="nextMonth()">next</button>
</div>
</nav>
<section class="dow">
<div v-for="day in days" :key="day" class="day">{{ day }}</div>
</section>
<section class="body">
<div v-for="week in cal.getWeeks()" :key="week" class="week">
<div
v-for="date in week.days()"
:key="date"
class="cell"
:class="{ oob: !cal.containsDate(date), today: cal.isToday(date) }"
>
<span class="date">{{ date.date }}</span>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { Calendar } from './'
import { ref } from 'vue'
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
//console.log(currentYear, currentMonth)
let cal = ref(Calendar.fromYm(currentYear, currentMonth))
const prevMonth = () => {
console.log('[prev]')
cal.value = cal.value.prevMonth()
}
const nextMonth = () => {
console.log('[next]')
cal.value = cal.value.nextMonth()
}
</script>
<style lang="scss" scoped>
$border-color: #efefef;
.calendar {
display: flex;
flex-direction: column;
height: 600px;
nav {
h3 {
margin: 0;
}
}
section {
&.dow {
display: flex;
.day {
flex: 1 1 calc(100% / 7);
width: calc(100% / 7);
border-top: 1px solid $border-color;
border-right: 1px solid $border-color;
border-bottom: 1px solid $border-color;
padding: 12px;
text-align: center;
&:first-child {
border-left: 1px solid $border-color;
color: #a4001f;
}
&:last-child {
color: #0064bb;
}
}
}
&.body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
.week {
display: flex;
flex-direction: row;
flex: 1 1 auto;
height: calc(100% / 6);
&:first-child {
.cell {
/* border-top: 1px solid $border-color; */
}
}
.cell {
flex: 1 1 calc(100% / 7);
width: calc(100% / 7);
position: relative;
border-bottom: 1px solid $border-color;
border-right: 1px solid $border-color;
&:first-child {
border-left: 1px solid $border-color;
}
&.oob {
//out of bound
.date {
background-color: transparent;
color: #ccc;
}
}
&.today {
.date {
background-color: #ffe5e9;
color: #a4001f;
}
}
.date {
position: absolute;
top: 6px;
right: 6px;
font-size: 12px;
background-color: #dcefff;
color: #0064bb;
border-radius: 20px;
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
}
}
}
}
}
}
</style>
2024 - 01 만 2023 - 01 로 오류가 나는 부분이 있다.
달력 라이브러리를 사용하지 않고 직접 하나하나 메서드와 클래스를 통해 달력을 구현해서 뿌듯하다. 그리고 TDD 를 통해 클래스 하나씩 테스트를 하는 것을 통해 프론트엔드를 하면서 실직적으로 작성하고 통과하는 과정을 배워서 유지보수하기 쉬울 것 같다는 생각을 했다.