diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..c64df8c
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,70 @@
+name: 'CI'
+
+on:
+  push:
+    branches:
+      - 'main'
+  pull_request:
+
+jobs:
+  test:
+    runs-on: docker
+
+    strategy:
+      matrix:
+        lisp:
+          - sbcl-bin
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Restore cache
+        id: restore-cache
+        uses: actions/cache/restore@v4
+        with:
+          path: |
+            ~/.roswell
+            /usr/local/bin/ros
+            /usr/local/etc/roswell/
+            qlfile
+            qlfile.lock
+            .qlot
+            ~/.cache/common-lisp/
+          key: roswell-${{ runner.os }}-${{ matrix.lisp }}-${{ hashFiles('qlfile', 'qlfile.lock', '*.asd') }}
+
+      - name: Install Roswell
+        if: steps.restore-cache.outputs.cache-hit != 'true'
+        env:
+          LISP: ${{ matrix.lisp }}
+        run: |
+          curl -L https://raw.githubusercontent.com/roswell/roswell/master/scripts/install-for-ci.sh | sh
+
+      - name: Install Qlot
+        if: steps.restore-cache.outputs.cache-hit != 'true'
+        run: |
+          ros install fukamachi/qlot
+
+      - name: Install dependencies
+        if: steps.restore-cache.outputs.cache-hit != 'true'
+        run: |
+          PATH="~/.roswell/bin:$PATH"
+          qlot install
+          qlot exec ros install microcms-lisp-sdk
+
+      - name: Save cache
+        id: save-cache
+        uses: actions/cache/save@v4
+        if: steps.restore-cache.outputs.cache-hit != 'true'
+        with:
+          path: |
+            ~/.roswell
+            /usr/local/bin/ros
+            /usr/local/etc/roswell/
+            qlfile
+            qlfile.lock
+            .qlot
+            ~/.cache/common-lisp/
+          key: ${{ steps.restore-cache.outputs.cache-primary-key }}
+
+      - name: Run tests
+        run: .qlot/bin/rove microcms-lisp-sdk.asd
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8e83954
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: 'CI'
+
+on:
+  push:
+    branches:
+      - 'main'
+  pull_request:
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        lisp:
+          - sbcl-bin
+          - ccl-bin
+
+    env:
+      LISP: ${{ matrix.lisp }}
+
+    steps:
+      - uses: actions/checkout@v4
+      - uses: 40ants/setup-lisp@v4
+        with:
+          asdf-system: microcms-lisp-sdk
+      - uses: 40ants/run-tests@v2
+        with:
+          asdf-system: microcms-lisp-sdk
diff --git a/README.md b/README.md
index 6a911c7..2771676 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,9 @@ Before making API requests, set your API key and service domain:
 
 Use `define-list-client` macro to define functions for list-type content.
 
+`query` and `content` must be provided as property list (plist), with keys written in kebab-case (e.g., `:draft-key`).
+The JSON response from the microCMS API are automatically converted into plist, with keys transformed from camelCase to kebab-case.
+
 ```lisp
 (microcms:define-list-client article)
 ```
@@ -30,8 +33,6 @@ This will generate the following functions:
 | `update-article` | (`id`, `content`) | Update an existing article by its ID with new content. |
 | `delete-article` | (`id`) | Delete an article by its ID. |
 
-Note: query arguments should be provided as a property list (plist), where keys use kebab-case (e.g., `:draft-key`).
-
 ### Object Type Endpoint
 
 Use `define-object-client` macro to define functions for object-type content.
diff --git a/microcms-test.asd b/microcms-test.asd
new file mode 100644
index 0000000..6ca4d4c
--- /dev/null
+++ b/microcms-test.asd
@@ -0,0 +1,6 @@
+(defsystem "microcms-test"
+  :class :package-inferred-system
+  :pathname "tests"
+  :depends-on ("rove"
+               "microcms-test/client")
+  :perform (test-op (o c) (symbol-call :rove :run c :style :dot)))
diff --git a/microcms.asd b/microcms.asd
index ba0929f..7d93c97 100644
--- a/microcms.asd
+++ b/microcms.asd
@@ -5,4 +5,5 @@
   :license "MIT"
   :class :package-inferred-system
   :pathname "src"
-  :depends-on ("microcms/main"))
+  :depends-on ("microcms/main")
+  :in-order-to ((test-op (test-op "microcms-test"))))
diff --git a/qlfile b/qlfile
index 4771eba..23d9290 100644
--- a/qlfile
+++ b/qlfile
@@ -3,3 +3,5 @@ ql dexador
 ql jzon
 ql quri
 ql kebab
+git rove https://github.com/fukamachi/rove
+git dissect https://github.com/Shinmera/dissect
diff --git a/qlfile.lock b/qlfile.lock
index 0b9791b..243bb2d 100644
--- a/qlfile.lock
+++ b/qlfile.lock
@@ -22,3 +22,11 @@
  (:class qlot/source/ql:source-ql
   :initargs (:%version :latest)
   :version "ql-2015-06-08"))
+("rove" .
+ (:class qlot/source/git:source-git
+  :initargs (:remote-url "https://github.com/fukamachi/rove")
+  :version "git-9868028edf511da5fe61355881af9f4c35f051df"))
+("dissect" .
+ (:class qlot/source/git:source-git
+  :initargs (:remote-url "https://github.com/Shinmera/dissect")
+  :version "git-a70cabcd748cf7c041196efd711e2dcca2bbbb2c"))
diff --git a/src/client.lisp b/src/client.lisp
new file mode 100644
index 0000000..578167a
--- /dev/null
+++ b/src/client.lisp
@@ -0,0 +1,108 @@
+(defpackage #:microcms/client
+  (:use #:cl)
+  (:import-from #:alexandria
+                #:remove-from-plist
+                #:symbolicate
+                #:alist-hash-table
+                #:hash-table-alist
+                #:make-keyword)
+  (:import-from #:com.inuoe.jzon
+                #:stringify
+                #:parse)
+  (:import-from #:dexador
+                #:request
+                #:http-request-failed
+                #:response-status)
+  (:import-from #:quri
+                #:make-uri
+                #:render-uri)
+  (:import-from #:kebab
+                #:to-camel-case
+                #:to-kebab-case)
+  (:export #:*api-key*
+           #:*service-domain*
+           #:define-list-client
+           #:define-object-client
+           #:%build-uri
+           #:%build-query
+           #:%build-content
+           #:%parse-response))
+(in-package #:microcms/client)
+
+(defparameter *api-key* nil)
+(defparameter *service-domain* nil)
+
+(defun %request (method endpoint &key path query content)
+  (or *service-domain* (error "microcms:*service-domain* is not configured."))
+  (or *api-key* (error "microcms:*api-key* is not configured."))
+  (handler-case
+      (multiple-value-bind (res-body status res-headers)
+          (request (%build-uri endpoint :path path :query query)
+                   :method method
+                   :headers `(("X-MICROCMS-API-KEY" . ,*api-key*)
+                              ("Content-Type" . "application/json"))
+                   :content (%build-content content)
+                   :force-binary nil)
+        (format t "microCMS status: ~D~%" status)
+        (when (and (stringp res-body)
+                   (search "application/json" (gethash "content-type" res-headers)))
+          (%parse-response res-body)))
+    (http-request-failed (e)
+      (format *error-output* "microCMS status: ~D~%" (response-status e)))))
+
+(defun %build-uri (endpoint &key path query)
+  (render-uri (make-uri :scheme "https"
+                        :host (format nil "~A.microcms.io" *service-domain*)
+                        :path (format nil "/api/v1/~A~@[/~A~]" endpoint path)
+                        :query (%build-query query))))
+
+(defun %kebab-case-plist->camel-case-alist (plist)
+  (loop :for (key val) :on plist :by #'cddr
+        :collect (cons (to-camel-case (symbol-name key)) val)))
+
+(defun %camel-case-hash-table->kebab-case-plist (hash-table)
+  (loop :for (key . val) :in (hash-table-alist hash-table)
+        :append (list (make-keyword (string-upcase (to-kebab-case key))) val)))
+
+(defun %build-query (query)
+  (%kebab-case-plist->camel-case-alist query))
+
+(defun %build-content (content)
+  (and content (stringify (alist-hash-table (%kebab-case-plist->camel-case-alist content)))))
+
+(defun %parse-response (body)
+  (%camel-case-hash-table->kebab-case-plist (parse body)))
+
+(defmacro define-list-client (endpoint)
+  (let ((str-endpoint (string-downcase (string endpoint)))
+        (get-list (symbolicate 'get- endpoint '-list))
+        (get-detail (symbolicate 'get- endpoint '-detail))
+        (create (symbolicate 'create- endpoint))
+        (update (symbolicate 'update- endpoint))
+        (delete (symbolicate 'delete- endpoint)))
+    `(list
+       (defun ,get-list (&key query)
+         (%request :get ,str-endpoint :query query))
+       (defun ,get-detail (id &key query)
+         (%request :get ,str-endpoint :path id :query query))
+       (defun ,create (content &key query)
+         (let ((id (getf content :id)))
+           (%request (if id :put :post)
+                     ,str-endpoint
+                     :path id
+                     :query query
+                     :content (remove-from-plist content :id))))
+       (defun ,update (id content)
+         (%request :patch ,str-endpoint :path id :content content))
+       (defun ,delete (id)
+         (%request :delete ,str-endpoint :path id)))))
+
+(defmacro define-object-client (endpoint)
+  (let ((str-endpoint (string-downcase (string endpoint)))
+        (get-object (symbolicate 'get- endpoint '-object))
+        (update (symbolicate 'update- endpoint)))
+    `(list
+       (defun ,get-object (&key query)
+         (%request :get ,str-endpoint :query query))
+       (defun ,update (content)
+         (%request :patch ,str-endpoint :content content)))))
diff --git a/src/main.lisp b/src/main.lisp
index 746e68d..b134596 100644
--- a/src/main.lisp
+++ b/src/main.lisp
@@ -1,92 +1,9 @@
-(defpackage #:microcms
+(uiop:define-package :microcms
   (:nicknames #:microcms/main)
-  (:use #:cl)
-  (:import-from #:alexandria
-                #:remove-from-plist
-                #:symbolicate
-                #:plist-hash-table)
-  (:import-from #:com.inuoe.jzon
-                #:stringify
-                #:parse)
-  (:import-from #:dexador
-                #:request
-                #:http-request-failed
-                #:response-status)
-  (:import-from #:quri
-                #:make-uri
-                #:render-uri)
-  (:import-from #:kebab
-                #:to-camel-case)
+  (:use #:cl
+        #:microcms/client)
   (:export #:*api-key*
            #:*service-domain*
            #:define-list-client
            #:define-object-client))
-(in-package #:microcms)
-
-(defparameter *api-key* nil)
-(defparameter *service-domain* nil)
-
-(defun %request (method endpoint &key path query content)
-  (or *service-domain* (error "microcms:*service-domain* is not configured."))
-  (or *api-key* (error "microcms:*api-key* is not configured."))
-  (handler-case
-      (multiple-value-bind (res-body status res-headers)
-          (request (%build-uri endpoint :path path :query query)
-                   :method method
-                   :headers `(("X-MICROCMS-API-KEY" . ,*api-key*)
-                              ("Content-Type" . "application/json"))
-                   :content (%build-content content)
-                   :force-binary nil)
-        (format t "microCMS status: ~D~%" status)
-        (when (and (stringp res-body)
-                   (search "application/json" (gethash "content-type" res-headers)))
-          (parse res-body)))
-    (http-request-failed (e)
-      (format *error-output* "microCMS status: ~D~%" (response-status e)))))
-
-(defun %build-uri (endpoint &key path query)
-  (render-uri (make-uri :scheme "https"
-                        :host (format nil "~A.microcms.io" *service-domain*)
-                        :path (format nil "/api/v1/~A~@[/~A~]" endpoint path)
-                        :query (%build-query query))))
-
-(defun %build-query (query)
-  (loop :for (key val) :on query :by #'cddr
-        :collect (cons (to-camel-case (symbol-name key)) val)))
-
-(defun %build-content (content)
-  (and content (stringify (plist-hash-table content))))
-
-(defmacro define-list-client (endpoint)
-  (let ((str-endpoint (string-downcase (string endpoint)))
-        (get-list (symbolicate 'get- endpoint '-list))
-        (get-detail (symbolicate 'get- endpoint '-detail))
-        (create (symbolicate 'create- endpoint))
-        (update (symbolicate 'update- endpoint))
-        (delete (symbolicate 'delete- endpoint)))
-    `(list
-       (defun ,get-list (&key query)
-         (%request :get ,str-endpoint :query query))
-       (defun ,get-detail (id &key query)
-         (%request :get ,str-endpoint :path id :query query))
-       (defun ,create (content &key query)
-         (let ((id (getf content :|id|)))
-           (%request (if id :put :post)
-                     ,str-endpoint
-                     :path id
-                     :query query
-                     :content (remove-from-plist content :|id|))))
-       (defun ,update (id content)
-         (%request :patch ,str-endpoint :path id :content content))
-       (defun ,delete (id)
-         (%request :delete ,str-endpoint :path id)))))
-
-(defmacro define-object-client (endpoint)
-  (let ((str-endpoint (string-downcase (string endpoint)))
-        (get-object (symbolicate 'get- endpoint '-object))
-        (update (symbolicate 'update- endpoint)))
-    `(list
-       (defun ,get-object (&key query)
-         (%request :get ,str-endpoint :query query))
-       (defun ,update (content)
-         (%request :patch ,str-endpoint :content content)))))
+(in-package :microcms)
diff --git a/tests/client.lisp b/tests/client.lisp
new file mode 100644
index 0000000..4a3a09e
--- /dev/null
+++ b/tests/client.lisp
@@ -0,0 +1,46 @@
+(defpackage #:microcms-test/client
+  (:use #:cl
+        #:rove
+        #:microcms/client))
+(in-package #:microcms-test/client)
+
+(deftest build-uri-test
+  (testing "build-uri constructs correct endpoint URLs"
+    (let ((*service-domain* "my-service"))
+      (ok (string= (%build-uri "article")
+                   "https://my-service.microcms.io/api/v1/article"))
+      (ok (string= (%build-uri "article" :path "abc123")
+                   "https://my-service.microcms.io/api/v1/article/abc123"))
+      (ok (string= (%build-uri "article" :query '(:limit 10 :offset 5))
+                   "https://my-service.microcms.io/api/v1/article?limit=10&offset=5")))))
+
+(deftest build-query-test
+  (testing "build-query converts kebab-case plist to camelCase alist"
+    (let ((result (%build-query '(:foo 1 :bar-baz 2))))
+      (ok (equal result '(("foo" . 1) ("barBaz" . 2)))))))
+
+(deftest build-content-test
+  (testing "build-content converts kebab-case plist to camelCase JSON"
+    (let ((json (%build-content '(:title "Hello" :created-at "2025-04-01"))))
+      (ok (search "\"title\":\"Hello\"" json))
+      (ok (search "\"createdAt\":\"2025-04-01\"" json)))))
+
+(deftest parse-response-test
+  (testing "parse-response converts camelCase JSON to kebab-case plist"
+    (let ((result (%parse-response "{\"myTitle\": \"Hello\", \"createdAt\": \"2025-04-01\"}")))
+      (ok (equal (getf result :my-title) "Hello"))
+      (ok (equal (getf result :created-at) "2025-04-01")))))
+
+(deftest macro-test
+  (testing "define-list-client creates expected functions"
+    (define-list-client blog)
+    (ok (fboundp 'get-blog-list))
+    (ok (fboundp 'get-blog-detail))
+    (ok (fboundp 'create-blog))
+    (ok (fboundp 'update-blog))
+    (ok (fboundp 'delete-blog)))
+
+  (testing "define-object-client creates expected functions"
+    (define-object-client config)
+    (ok (fboundp 'get-config-object))
+    (ok (fboundp 'update-config))))