Golang Todolist CLI #3 β Menambahkan Command CLI Interaktif

Halo selamat datang kembali di seri Golang Todolist CLI bersama Ajitama! π
Setelah sebelumnya kita membuat model Task dan repository-nya, sekarang saatnya membuat aplikasi kita bisa dikendalikan langsung dari terminal!
Di seri ini kita akan:
β
Menambahkan command CLI: add, list, delete, dan complete
β
Menggunakan package survey sebagai framework CLI interaktif
β Menyusun struktur file sesuai best practice
π¦ Step 1 β Install Survey
Pertama, kita akan meng-install [survey](https://github.com/AlecAivazis/survey), package populer untuk membuat CLI interaktif di Golang:
go get github.com/AlecAivazis/survey/v2π Step 2 β Struktur Folder
Kita tambahkan folder cli/ untuk menyimpan semua logic CLI:
golang-todolist-cli/
βββ internal/
β βββ cli/
β βββ interactive.go
βββ main.goπ§ Step 3 β Buat Interactive CLI
File: internal/cli/interactive.go
package cli
import (
"fmt"
"os"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fardannozami/golang-todolist-cli/internal/model"
"github.com/fardannozami/golang-todolist-cli/internal/repository"
)
type CLI struct {
repo repository.TaskRepository
PromptRunner func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error
ExitFunc func(code int)
}
func NewCLI(repo repository.TaskRepository) *CLI {
return &CLI{
repo: repo,
PromptRunner: survey.AskOne,
ExitFunc: os.Exit,
}
}
func (c *CLI) Run() {
for {
if !c.RunOnce() {
break
}
}
}
func (c *CLI) RunOnce() bool {
choice := ""
prompt := &survey.Select{
Message: "Apa yang ingin kamu lakukan?",
Options: []string{"β Add Task", "π List Tasks", "β
Complete Task", "ποΈ Delete Task", "πͺ Exit"},
}
c.PromptRunner(prompt, &choice)
switch choice {
case "β Add Task":
c.addTask()
case "π List Tasks":
c.listTasks()
case "β
Complete Task":
c.completeTask()
case "ποΈ Delete Task":
c.deleteTask()
case "πͺ Exit":
fmt.Println("Sampai jumpa π")
c.ExitFunc(0)
return false
}
return true
}
func (c *CLI) addTask() {
var desc string
prompt := &survey.Input{Message: "Masukkan deskripsi task:"}
c.PromptRunner(prompt, &desc)
if desc == "" {
fmt.Println("β Deskripsi tidak boleh kosong")
return
}
task := model.Task{
Id: time.Now().Nanosecond(),
Description: desc,
CreatedAt: time.Now(),
}
c.repo.AddTask(task)
fmt.Println("β
Task berhasil ditambahkan!")
}
func (c *CLI) listTasks() {
tasks, _ := c.repo.GetAllTasks()
if len(tasks) == 0 {
fmt.Println("π Tidak ada task.")
return
}
fmt.Println("π Daftar Task:")
for _, task := range tasks {
status := "β"
if task.CompletedAt != nil {
status = "β
"
}
fmt.Printf("[%s] #%d - %s\n", status, task.Id, task.Description)
}
}
func (c *CLI) completeTask() {
tasks, _ := c.repo.GetAllTasks()
if len(tasks) == 0 {
fmt.Println("π Tidak ada task.")
return
}
options := []string{}
taskMap := map[string]int{}
for _, t := range tasks {
label := fmt.Sprintf("#%d - %s", t.Id, t.Description)
options = append(options, label)
taskMap[label] = t.Id
}
var selected string
prompt := &survey.Select{Message: "Pilih task yang ingin diselesaikan:", Options: options}
c.PromptRunner(prompt, &selected)
err := c.repo.MarkTaskAsCompleted(taskMap[selected])
if err != nil {
fmt.Println("β", err)
} else {
fmt.Println("β
Task selesai!")
}
}
func (c *CLI) deleteTask() {
tasks, _ := c.repo.GetAllTasks()
if len(tasks) == 0 {
fmt.Println("π Tidak ada task.")
return
}
options := []string{}
taskMap := map[string]int{}
for _, t := range tasks {
label := fmt.Sprintf("#%d - %s", t.Id, t.Description)
options = append(options, label)
taskMap[label] = t.Id
}
var selected string
prompt := &survey.Select{Message: "Pilih task yang ingin dihapus:", Options: options}
c.PromptRunner(prompt, &selected)
err := c.repo.DeleteTask(taskMap[selected])
if err != nil {
fmt.Println("β", err)
} else {
fmt.Println("ποΈ Task berhasil dihapus.")
}
}π§ͺ Step 4 β Unit Test
File: internal/cli/interactive_test.go
Kita akan menggunakan testify untuk mocking dan assertions:
go get github.com/stretchr/testifyBerikut kode pengujian CLI:
package cli
import (
"fmt"
"testing"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fardannozami/golang-todolist-cli/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock repository untuk testing
type MockTaskRepository struct {
mock.Mock
}
func (m *MockTaskRepository) AddTask(task model.Task) error {
args := m.Called(task)
return args.Error(0)
}
func (m *MockTaskRepository) GetAllTasks() ([]model.Task, error) {
args := m.Called()
return args.Get(0).([]model.Task), args.Error(1)
}
func (m *MockTaskRepository) DeleteTask(id int) error {
args := m.Called(id)
return args.Error(0)
}
func (m *MockTaskRepository) MarkTaskAsCompleted(id int) error {
args := m.Called(id)
return args.Error(0)
}
func TestNewCLI(t *testing.T) {
mockRepo := new(MockTaskRepository)
cli := NewCLI(mockRepo)
assert.NotNil(t, cli)
assert.Equal(t, mockRepo, cli.repo)
}
func TestCLI_ListTasks(t *testing.T) {
mockRepo := new(MockTaskRepository)
cli := NewCLI(mockRepo)
t.Run("daftar kosong", func(t *testing.T) {
mockRepo.On("GetAllTasks").Return([]model.Task{}, nil).Once()
cli.listTasks()
mockRepo.AssertExpectations(t)
})
t.Run("daftar berisi task", func(t *testing.T) {
completedTime := time.Now()
tasks := []model.Task{
{Id: 1, Description: "Task 1", CreatedAt: time.Now()},
{Id: 2, Description: "Task 2", CreatedAt: time.Now(), CompletedAt: &completedTime},
}
mockRepo.On("GetAllTasks").Return(tasks, nil).Once()
cli.listTasks()
mockRepo.AssertExpectations(t)
})
}
func TestCLI_AddTask(t *testing.T) {
mockRepo := new(MockTaskRepository)
mockRepo.On("AddTask", mock.AnythingOfType("model.Task")).Return(nil)
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = "Belajar Golang"
return nil
}
c.addTask()
mockRepo.AssertCalled(t, "AddTask", mock.AnythingOfType("model.Task"))
}
func TestCLI_CompleteTask(t *testing.T) {
mockRepo := new(MockTaskRepository)
tasks := []model.Task{
{Id: 101, Description: "Task Testing", CreatedAt: time.Now()},
}
mockRepo.On("GetAllTasks").Return(tasks, nil)
mockRepo.On("MarkTaskAsCompleted", 101).Return(nil)
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = "#101 - Task Testing"
return nil
}
c.completeTask()
mockRepo.AssertCalled(t, "MarkTaskAsCompleted", 101)
mockRepo.AssertExpectations(t)
}
func TestCLI_DeleteTask(t *testing.T) {
mockRepo := new(MockTaskRepository)
tasks := []model.Task{
{Id: 202, Description: "Task to delete", CreatedAt: time.Now()},
}
mockRepo.On("GetAllTasks").Return(tasks, nil)
mockRepo.On("DeleteTask", 202).Return(nil)
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = "#202 - Task to delete"
return nil
}
c.deleteTask()
mockRepo.AssertCalled(t, "DeleteTask", 202)
mockRepo.AssertExpectations(t)
}
func TestCLI_ListTasks_Error(t *testing.T) {
mockRepo := new(MockTaskRepository)
cli := NewCLI(mockRepo)
mockRepo.On("GetAllTasks").Return([]model.Task{}, fmt.Errorf("database error")).Once()
cli.listTasks()
mockRepo.AssertExpectations(t)
}
func TestCLI_AddTask_EmptyDescription(t *testing.T) {
mockRepo := new(MockTaskRepository)
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = ""
return nil
}
c.addTask()
mockRepo.AssertNotCalled(t, "AddTask")
}
func TestCLI_CompleteTask_Error(t *testing.T) {
mockRepo := new(MockTaskRepository)
tasks := []model.Task{
{Id: 101, Description: "Task Testing", CreatedAt: time.Now()},
}
mockRepo.On("GetAllTasks").Return(tasks, nil)
mockRepo.On("MarkTaskAsCompleted", 101).Return(fmt.Errorf("failed to mark task as completed"))
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = "#101 - Task Testing"
return nil
}
c.completeTask()
mockRepo.AssertExpectations(t)
}
func TestCLI_DeleteTask_Error(t *testing.T) {
mockRepo := new(MockTaskRepository)
tasks := []model.Task{
{Id: 202, Description: "Task to delete", CreatedAt: time.Now()},
}
mockRepo.On("GetAllTasks").Return(tasks, nil)
mockRepo.On("DeleteTask", 202).Return(fmt.Errorf("failed to delete task"))
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = "#202 - Task to delete"
return nil
}
c.deleteTask()
mockRepo.AssertExpectations(t)
}
func TestCLI_CompleteTask_EmptyList(t *testing.T) {
mockRepo := new(MockTaskRepository)
mockRepo.On("GetAllTasks").Return([]model.Task{}, nil)
c := NewCLI(mockRepo)
c.completeTask()
mockRepo.AssertExpectations(t)
}
func TestCLI_DeleteTask_EmptyList(t *testing.T) {
mockRepo := new(MockTaskRepository)
mockRepo.On("GetAllTasks").Return([]model.Task{}, nil)
c := NewCLI(mockRepo)
c.deleteTask()
mockRepo.AssertExpectations(t)
}
func TestCLI_RunOnce(t *testing.T) {
t.Run("add task", func(t *testing.T) {
mockRepo := new(MockTaskRepository)
mockRepo.On("AddTask", mock.AnythingOfType("model.Task")).Return(nil).Once()
c := NewCLI(mockRepo)
var promptCount int
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
if promptCount == 0 {
*response.(*string) = "β Add Task"
} else {
*response.(*string) = "Test Task"
}
promptCount++
return nil
}
shouldContinue := c.RunOnce()
assert.True(t, shouldContinue)
mockRepo.AssertExpectations(t)
})
t.Run("list tasks", func(t *testing.T) {
mockRepo := new(MockTaskRepository)
tasks := []model.Task{{Id: 1, Description: "Task 1", CreatedAt: time.Now()}}
mockRepo.On("GetAllTasks").Return(tasks, nil).Once()
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = "π List Tasks"
return nil
}
shouldContinue := c.RunOnce()
assert.True(t, shouldContinue)
mockRepo.AssertExpectations(t)
})
t.Run("complete task", func(t *testing.T) {
mockRepo := new(MockTaskRepository)
tasks := []model.Task{{Id: 1, Description: "Task 1", CreatedAt: time.Now()}}
mockRepo.On("GetAllTasks").Return(tasks, nil).Once()
mockRepo.On("MarkTaskAsCompleted", 1).Return(nil).Once()
c := NewCLI(mockRepo)
var promptCount int
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
if promptCount == 0 {
*response.(*string) = "β
Complete Task"
} else {
*response.(*string) = "#1 - Task 1"
}
promptCount++
return nil
}
shouldContinue := c.RunOnce()
assert.True(t, shouldContinue)
mockRepo.AssertExpectations(t)
})
t.Run("delete task", func(t *testing.T) {
mockRepo := new(MockTaskRepository)
tasks := []model.Task{{Id: 1, Description: "Task 1", CreatedAt: time.Now()}}
mockRepo.On("GetAllTasks").Return(tasks, nil).Once()
mockRepo.On("DeleteTask", 1).Return(nil).Once()
c := NewCLI(mockRepo)
var promptCount int
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
if promptCount == 0 {
*response.(*string) = "ποΈ Delete Task"
} else {
*response.(*string) = "#1 - Task 1"
}
promptCount++
return nil
}
shouldContinue := c.RunOnce()
assert.True(t, shouldContinue)
mockRepo.AssertExpectations(t)
})
t.Run("exit", func(t *testing.T) {
mockRepo := new(MockTaskRepository)
c := NewCLI(mockRepo)
exitCalled := false
c.ExitFunc = func(code int) {
exitCalled = true
assert.Equal(t, 0, code)
}
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
*response.(*string) = "πͺ Exit"
return nil
}
shouldContinue := c.RunOnce()
assert.False(t, shouldContinue)
assert.True(t, exitCalled)
})
t.Run("prompt error", func(t *testing.T) {
mockRepo := new(MockTaskRepository)
c := NewCLI(mockRepo)
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
return fmt.Errorf("prompt error")
}
shouldContinue := c.RunOnce()
assert.True(t, shouldContinue)
})
}
func TestCLI_Run(t *testing.T) {
mockRepo := new(MockTaskRepository)
c := NewCLI(mockRepo)
var runCount int
c.PromptRunner = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
if runCount == 0 {
*response.(*string) = "π List Tasks"
} else {
*response.(*string) = "πͺ Exit"
}
runCount++
return nil
}
mockRepo.On("GetAllTasks").Return([]model.Task{}, nil).Once()
exitCalled := false
c.ExitFunc = func(code int) {
exitCalled = true
assert.Equal(t, 0, code)
}
c.Run()
assert.True(t, exitCalled)
mockRepo.AssertExpectations(t)
}jalankan test dengan perintah
go test ./internal/cli -vπ Step 5 β Jalankan Aplikasi
File: main.go
package main
import (
"github.com/fardannozami/golang-todolist-cli/internal/cli"
"github.com/fardannozami/golang-todolist-cli/internal/repository"
)
func main() {
repo := repository.NewInMemoryTaskRepository()
app := cli.NewCLI(repo)
app.Run()
}Jalankan aplikasi:
go run main.goArticle Series
Golang Todolist CLI
Lanjutkan membaca seri ini untuk melihat perjalanan lengkapnya.
- 1Aplikasi Todo (CLI)8 Mei 20252 min read
- 2Golang Todolist CLI #1 β Menginisiasi Proyek8 Mei 20252 min read
- 3Golang Todolist CLI #2 β Membuat Model dan Repository Task8 Mei 20253 min read
- 4Golang Todolist CLI #3 β Menambahkan Command CLI Interaktif11 Mei 20257 min readCurrent article