見出し画像

【Java&SpringBoot3】早起きのWebアプリー機能追加(4)画像アプロード機能(設計・実装・テスト)


1. はじめに


こんにちは!
今日は昨日に続き、画像アプロード機能の設計・実装・テストまで、Waterfall工程方式の流れで開発してみます!

2. 画像アプロード機能の基本設計(08.16-08.17)


2.1 目的

自分が朝早く起きてやったことを写真で証明します。 その結果、朝起きたことへの誇りと他人からの信頼感を一緒に得ることができます。

2.2 機能構成

1.1 記事登録時に写真を最大3枚までアップロードすることができます。

1.2 事登録時にアップロードする写真のリストを変更することができます。

2.1 修正すると、既存のアップロードされた写真のリストが表示されます。

2.2 アップロードされた写真のリストを変更することができます。

2.3 新たに写真を追加しても最大3枚を超えないようにする。

3.1 記事を削除すると、アップロードされたファイルも削除する。


2.3 画面構成

KakaoOvenというUI構成ツールで作成しますた。

ファイル選択をクリックして
画像ファイルをインポートします。
下段に最大3つまでの画像を表示します。
上段にファイル名、下段にプレビューが表示されます。
記事がイメージファイルとともに登録されます。
該当記事を照会してカルーセルの形でイメージが表示されます。 左右の矢印を押すと、次のイメージに転換されます。
記事の修正時にアップロードされた画像を表示します。
削除ボタンを押すと、その画像は消えます。
修正して記事を照会すると、削除した写真は見当たりません。


2.4 データベースモデリング

ArticleエンティティはImageFileエンティティと1:N(一対多)関係を持ちます。 1つの投稿(Article)は、複数のImageFileを持つことができます。

ImageFileエンティティはArticleエンティティとN:1(複数対1)関係を持ちます。 複数のイImageFileは、1つの投稿に属することができます。

article_idはImageFileエンティティの外来キー(Foreign Key)で、どの投稿とつながっているかを示します。

3. 画像アプロード機能の詳細設計と実装(08.18-08.23)


3.0 アプロードための「Multipart」

Webアプリケーションで複数の種類のデータ(テキスト、ファイルなど)を同時に送信して処理する方法です。 主にファイルのアップロードを含むいくつかのタイプのデータを送信するときに使用されます。 HTMLフォーム(form)でenctype="multipart/form-data"プロパティを使用して指定します。

通常、ウェブフォームはテキスト データのみをサーバーに送信するために使用されます。 ただし、ファイルを含むデータを転送するには、マルチパートフォームを使用する必要があります。 マルチパートはテキストやファイルなどのデータを分けて送信し、サーバーからこれらのデータを受け取って処理できるようにします。

Multipartの特徴?

  • Content-Type変更: マルチパートフォームは、enctypeプロパティをmultipart/form-dataに設定し、ウェブブラウザにデータ転送方式を知らせます。

  • 各部分区分: それぞれのマルチパート部分は区分子(delimitor)で区分されており、各部分はデータのタイプ、大きさ、名前などの情報を含みます。

例:

--boundary123
Content-Disposition: form-data; name="text"

Hello, World!
--boundary123
Content-Disposition: form-data; name="file"; filename="example.txt"
Content-Type: text/plain

... (file data)
--boundary123--

--boundary123が区分者です。
Content-Dispositionヘッダーなどを使用して各部分の情報も一緒に伝達します。

  • ファイルアップロード: 主にファイルアップロードに使用され、ファイルの元の名前、サイズ、MIMEタイプなどの情報とともにサーバーに送信されます。

  • サーバーでの処理: サーバー側では、マルチパート データをパーシングしてテキスト データとファイル データを抽出および処理します。

Javaでマルチパートデータを処理するためには主にjavax。servletパッケージのクラスを使用し、ServletでHttpServletRequestオブジェクトを通じてMultipartデータを処理することができます。 Springでは、MultipartFileオブジェクトを使用してファイルアップロードを処理する機能を提供します。

3.1.1 Domain - ImageFile.java

@Entity
@Data
public class ImageFile {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "article_id")
    private Article article;

    private String filename;
    private String contentType;

}


3.1.2 Domain - Article.java

@Entity
@Data 
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "content", nullable = false)
    private String content;

    @Column(name = "author", nullable = false)
    private String author;
    
    //画像ファイルリスト
    @OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
    private List<ImageFile> imageFiles; 


    @CreatedDate
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @Builder
    public Article(Long articleId, String author, String title, String content, List<ImageFile> imageFiles) {
        this.author = author;
        this.title = title;
        this.content = content;
        this.imageFiles = imageFiles;

    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;


    }

imageFilesフィールド
該当掲示物に接続された画像ファイルを示します。

@OneToMany
Articleエンティティは複数の画像ファイルを持つことができ、imageFilesフィールドはこの関係を表現します。。 つまり、1つのArticleエンティティが複数のImageFileエンティティに関連する可能性があることを意味します。

cascade = CascadeType.ALL
投稿に連結された画像ファイルはデータベース内で管理される部分で、Articleエンティティを保存、修正または削除する時に関連する画像ファイルも処理されます。

3.2 Repository ImageRepository.java

public interface ImageRepository extends JpaRepository<ImageFile, Long> {
 
  List<ImageFile> findByArticle(Article article);

}

Spring Data JPAを使用してImage Repositoryインタフェースを定義する部分です。

ImageFileエンティティを掲示物に関連する画像ファイルを照会するために使用します。 パラメータとして受け取ったArticleオブジェクトに関連する画像ファイルのリストを返します。 これはSpring Data JPAの命名規則に従って自動的にクエリを生成します。


3.3 Service - BlogApiService.java

// 記事を保存します
public Article save(AddArticleRequest request, String userName) throws Exception {
    Article article = request.toEntity(userName);
    // 画像ファイル数の制限はチェックしません
    boolean shouldLoadExistingImageFiles = false; 

    // ここで画像ファイルを処理します
    if(request.getImageFiles() != null) {
        List<ImageFile> imageFiles = saveImageFiles(request.getImageFiles(), article, shouldLoadExistingImageFiles);
        article.setImageFiles(imageFiles);
    }

    return blogRepository.save(article);
}

save

save メソッドは、新しい記事を作成してデータベースに保存するためのものです。

request.toEntity(userName) で AddArticleRequest を使って新しい記事のエンティティを作成します。

shouldLoadExistingImageFiles 変数は、画像ファイルの制限をチェックするためのフラグです。この場合、新しい記事を作成する際は制限をチェックしません。

request.getImageFiles() でリクエストから画像ファイルを取得し、saveImageFiles メソッドを使って画像ファイルを処理します。

処理された画像ファイルは、記事のエンティティに関連付けられてセットされます。

最終的に、作成された記事をデータベースに保存し、その記事エンティティを返します。

@Transactional
public Article update(long id, UpdateArticleRequest request) throws Exception {
    Article article = blogRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("not found : " + id));

    authorizeArticleAuthor(article);
    // 画像ファイル数の制限をチェックします
    boolean shouldLoadExistingImageFiles = true;

    // ここで画像ファイルを処理します
    if(request.getImageFiles() != null) {
        List<ImageFile> imageFiles = saveImageFiles(request.getImageFiles(), article, shouldLoadExistingImageFiles);
        article.setImageFiles(imageFiles);
    }

    article.update(request.getTitle(), request.getContent());

    return article;
}


// 画像を削除します
public void deleteImage(Long id) {
    ImageFile imageFile = imageRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Image not found with id: " + id));
  
    // ファイルクラスを使用してファイルを削除する
    File fileToDelete = new File("C:/Users/.../src/main/resources/static/article_img/" + imageFile.getFilename());
    if (fileToDelete.exists()) {
        fileToDelete.delete();
    }

    imageRepository.delete(imageFile);
}

   


updateメソッド
Transactionalアノテーションはトランザクション内で実行され、データベース操作が成功するとコミットされ、失敗するとロールバックされます。

updateメソッドでデータ更新と画像ファイル追加作業が一緒に行われる場合、これらの作業が一つのトランザクションで実行されなければ一貫性を維持できません。

与えられたidに該当する既存のArticleを照会後、authorizeArticleAuthorメソッドを使って現在のユーザーがその投稿の著者かどうかを確認します。

その後、画像ファイルを更新したり追加したり、requestで受け取ったtitleとcontentで投稿内容を更新し、最終的に更新されたArticleを返します。

画像ファイル処理の際に、requestに画像ファイルが含まれている場合、その画像ファイルをsaveImageFilesメソッドで保存し、その後articleオブジェクトに画像ファイルリストを設定します。


deleteImageメソッド
記事修正時に既にアップロードされた画像を削除するときに使います。与えられたIDに該当する画像ファイルを照会し、該当IDがなければ例外をスローします。 その後、画像ファイルを物理的に削除した後、データベースから該当画像を削除します。
 

// 画像ファイルを保存します
private List<ImageFile> saveImageFiles(List<MultipartFile> imageFiles, Article article, boolean shouldLoadExistingImageFiles) throws Exception {
    List<ImageFile> savedImageFiles = new ArrayList<>();

    // 画像ファイル数の制限ロジック
    int totalImageFilesCount = imageFiles.size();
    if (shouldLoadExistingImageFiles) {
        List<ImageFile> existingImageFiles = imageRepository.findByArticle(article);
        totalImageFilesCount += existingImageFiles.size();
    }

    // 画像ファイル数の制限: 最大3つ
    int imageFileLimit = 3;

    if (totalImageFilesCount <= imageFileLimit) {
        for (MultipartFile imageFile : imageFiles) {
            try {
                // ファイル情報の取得
                String originalFilename = imageFile.getOriginalFilename();
                String contentType = imageFile.getContentType();
                byte[] fileBytes = imageFile.getBytes();

                // ファイル名から拡張子を除いた部分を抽出
                int lastDotIndex = originalFilename.lastIndexOf(".");
                String filenameWithoutExtension = originalFilename.substring(0, lastDotIndex);

                // ファイルの拡張子を取得
                String fileExtension = originalFilename.substring(originalFilename.lastIndexOf("."));

                // UUIDの生成
                String uuid = UUID.randomUUID().toString();

                // 保存するファイル名の生成(拡張子を除くファイル名 + UUID + 現在の時間 + 拡張子)
                String filename = filenameWithoutExtension + "_" + uuid + "_" + System.currentTimeMillis() + fileExtension;

                String storagePath = "C:/Users/.../src/main/resources/static/article_img/";

                // ファイルの保存ロジックを実装
                File savedFile = new File(storagePath + filename);
                Files.write(savedFile.toPath(), fileBytes);

                // ImageFileエンティティの作成と保存
                ImageFile image = new ImageFile();
                image.setArticle(article);
                image.setFilename(filename);
                image.setContentType(contentType);
                savedImageFiles.add(image);

            } catch (IOException e) {
                e.printStackTrace();
                // ファイルの保存に失敗した場合の処理
            }
        }
    } else {
        throw new Exception("Image file limit exceeded.");
    }

    return savedImageFiles;
}

saveImageFilesメソッド
save、updateで使われます。与えられたイメージファイルを検査して保存し、イメージファイル数の制限を適用して新しいImageFileエンティティを生成して関連されたArticleと連結します。

ファイル処理を行う最も重要なメソッドであるだけに、詳しく見てみましょう。

  1. 画像ファイル保存
    与えられた画像ファイルのリストを使用して、Articleに関連する画像ファイルを保存し、いくつかの制限条件を確認します。

  2. 画像ファイル数の制限
    既存の画像ファイルを含む総画像ファイル数を計算し、画像ファイルの最大許容数を確認します。

  3. 画像ファイルの保存
    画像ファイル数が制限内にある場合は、特定の画像ファイルを巡回し、各画像に対して次のタスクを実行します。

    1. ファイル情報の抽出
      イメージ ファイルのソース名、コンテンツ タイプ、バイト データを抽出します。

    2. ファイル名の作成
      画像ファイルの元の名前から拡張子を除いた部分とUUID、現在時間などを組み合わせて新しいファイル名を生成します。

    3. ファイルの保存
      生成されたファイル名とパスを使用して画像ファイルを保存します。

    4. 画像エンティティ生成
      保存された画像ファイルに関する情報を使用してImageFileエンティティを生成し、そのエンティティをリストに追加します。

  4. 画像ファイル数制限超過
    画像ファイル数が制限を超える場合は、例外を投げかけます。


// 記事を削除します
public void delete(long id) {
    Article article = blogRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("not found : " + id));

    authorizeArticleAuthor(article);

    // 画像ファイルも一緒に削除します
    List<ImageFile> imageFiles = article.getImageFiles();
    if (imageFiles != null) {
        for (ImageFile imageFile : imageFiles) {
            deleteAllImage(imageFile.getId()); // 画像の削除ロジックを呼び出します
        }
    }

    blogRepository.delete(article);
}

// すべての画像を削除します
public void deleteAllImage(Long id) {
    ImageFile imageFile = imageRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Image not found with id: " + id));

   
    // ファイルクラスを使用してファイルを削除する
    File fileToDelete = new File("C:/Users/.../src/main/resources/static/article_img/" + imageFile.getFilename());
    if (fileToDelete.exists()) {
        fileToDelete.delete();
    }

    imageRepository.delete(imageFile);
}

deleteメソッド
与えられたidに該当する投稿をデータベースで照会します。
投稿の作成者権限を確認し、作成者でない場合は例外を投じます。
投稿と画像ファイルが接続されている場合は、各画像ファイルに対してdeleteAllImageメソッドを呼び出して画像ファイルを削除します。
投稿をデータベースから削除します。

deleteAllImageメソッド

与えられたidに対応する画像ファイルをデータベースで照会します。
画像ファイルを物理的に削除します。
画像ファイルをデータベースから削除します。

3.5 Controller - BlogController.java

@PostMapping("/api/articles")
    public ResponseEntity<Article> addArticle(@RequestParam("title") String title,
                                              @RequestParam("content") String content,
                                              @RequestParam(value = "imageFiles[]", required = false) List<MultipartFile> imageFiles,
                                              Principal principal) throws Exception {
        AddArticleRequest request = new AddArticleRequest();
        request.setTitle(title);
        request.setContent(content);
        request.setImageFiles(imageFiles);

        Article savedArticle = blogService.save(request, principal.getName());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(savedArticle);
    }



addArticle
HTTP POSTリクエストで記事の情報(タイトル、コンテンツ、画像ファイル)を受け取り、新しい記事を作成します。

AddArticleRequest クラスを使用してリクエストデータをパースし、blogService を介して記事を作成して保存します。

@PostMapping("/api/articles/{id}")
    public ResponseEntity<Article> updateArticle(@PathVariable long id,
                                                 @RequestParam("title") String title,
                                                 @RequestParam("content") String content,
                                                 @RequestParam(value = "imageFiles[]", required = false) List<MultipartFile> imageFiles
                                                 ) throws Exception {


        UpdateArticleRequest request = new UpdateArticleRequest();
        request.setTitle(title);
        request.setContent(content);
        request.setImageFiles(imageFiles);


        Article updatedArticle = blogService.update(id, request);

        return ResponseEntity.ok()
                .body(updatedArticle);
    }

updateArticle

HTTP POSTリクエストで記事の情報(タイトル、コンテンツ、画像ファイル)を受け取り、指定されたIDの記事を更新します。UpdateArticleRequest クラスを使用してリクエストデータをパースし、blogService を介して記事を更新します。

@DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Void> deleteArticle(@PathVariable long id) {
        blogService.delete(id);

        return ResponseEntity.ok()
                .build();
    }

deleteArticle
HTTP DELETEリクエストで指定されたIDの記事を削除します。blogService を介して記事を削除します。

 @DeleteMapping("/api/images/{id}")
    public ResponseEntity<?> deleteImage(@PathVariable Long id) {
        blogService.deleteImage(id);
        return ResponseEntity.ok().build();
    }

deleteImage
HTTP DELETEリクエストで指定されたIDの画像を削除します。blogService を介して画像を削除します。記事修正時に既にアップロードされた画像を削除するときに使います。

3.6.1 DTO - AddArticleRequest.java

@NoArgsConstructor
@AllArgsConstructor
@Data
public class AddArticleRequest {

    private String title;
    private String content;
    private List<MultipartFile> imageFiles; // 画像ファイルリスト

    public Article toEntity(String author) {
        return Article.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

既存のコードに掲示物に添付された画像ファイルを表すリストフィールドを追加しました。

3.6.2 DTO - ArticleViewResponse.java

@NoArgsConstructor
@Getter
public class ArticleViewResponse {

    private Long id;
    private String title;
    private String content;
    private LocalDateTime createdAt;
    private String author;
    private List<ImageFile> imageFiles; // 画像ファイルリスト

    public ArticleViewResponse(Article article) {
        this.id = article.getId();
        this.title = article.getTitle();
        this.content = article.getContent();
        this.createdAt = article.getCreatedAt();
        this.author = article.getAuthor();
        this.imageFiles = article.getImageFiles(); // 画像ファイルリスト
    }
}


ここでも、既存のコードに掲示物に添付された画像ファイルを表すリストフィールドを追加しました。 記事を照会する際に添付された画像ファイルが見られるようにフィールドを追加したものです。

3.7 HttpRequest - article.js

// 登録機能
const createButton = document.getElementById('create-btn');

if (createButton) {
    createButton.addEventListener('click', event => {

        const formData = new FormData();
        formData.append('title', document.getElementById('title').value);
        formData.append('content', document.getElementById('content').value);

        // 画像ファイルの追加
        const imageFiles = document.querySelector('input[name="imageFiles"]').files;

        // 画像ファイルを別のキーで追加
        for (const imageFile of imageFiles) {
            formData.append('imageFiles[]', imageFile);
        }

        function success() {
            alert('登録が完了しました。');
            location.replace('/articles');
        }

        function fail() {
            alert('登録に失敗しました。');
            location.replace('/articles');
        }

        httpRequestWithFormData('POST', '/api/articles', formData, success, fail);
    });
}

// FormDataを使ってファイルアップロードを含むHTTPリクエストを送る関数。
function httpRequestWithFormData(method, url, formData, success, fail) {

    fetch(url, {
        method: method,
        headers: {
            Authorization: 'Bearer ' + localStorage.getItem('access_token'),
        },
        body: formData,
    }).then(response => {
        if (response.status === 200 || response.status === 201) {
            return success();
        }
        const refresh_token = getCookie('refresh_token');
                if (response.status === 401 && refresh_token) {
                    fetch('/api/token', {
                            method: 'POST',
                            headers: {
                                Authorization: 'Bearer ' + localStorage.getItem('access_token'),
                                'Content-Type': 'multipart/form-data',
                            },
                            body: JSON.stringify({
                                refreshToken: getCookie('refresh_token'),
                            }),
                        })
                        .then(res => {
                            if (res.ok) {
                                return res.json();
                            }
                        })
                        .then(result => { // 再発行に成功すると、ローカルストレージの値を新しいアクセストークンに置き換えます。
                            localStorage.setItem('access_token', result.accessToken);
                            httpRequestWithFormData(method, url, formData, success, fail);
                        })
                        .catch(error => fail());
                } else {
                    return fail();
                }
    });
}

記事登録

FormData オブジェクトを作成して、タイトルとコンテンツを追加します。

さらに、画像ファイルを imageFiles[] キーで追加します。

複数のファイルが選択された場合にも対応できるようになっています。

添付ファイルをアップロードする際に重要な点は、htpリクエストでヘッダーに「Content-Type」が「application/json」ではなく「multipart/form-data」と書く必要があることです。

// 修正機能
const modifyButton = document.getElementById('modify-btn');

if (modifyButton) {

    modifyButton.addEventListener('click', event => {
        let params = new URLSearchParams(location.search);
        let id = params.get('id');

        const formData = new FormData();
        formData.append('title', document.getElementById('title').value);
        formData.append('content', document.getElementById('content').value);

        // 画像ファイルの追加
        const imageFiles = document.querySelector('input[name="imageFiles"]').files;

        for (const imageFile of imageFiles) {
                    formData.append('imageFiles[]', imageFile);
                }


        function success() {
            alert('修正が完了しました。');
            location.replace(`/articles/${id}`);
        }

        function fail() {
            alert('修正に失敗しました。');
            location.replace(`/articles/${id}`);
        }

        httpRequestWithFormData('POST', `/api/articles/${id}`, formData, success, fail);
    });
}

記事修正

URLSearchParams オブジェクトを使用して、クエリパラメータから記事のIDを取得しています。これにより、どの記事を修正するかを特定します。

登録の場合は新しい記事を作成するためのURLである /api/articles が使用されましたが、修正の場合は既存の記事を修正するためのURLである /api/articles/${id} が使用されています。

HTTPメソッドについては、このコードでは、修正(更新)を行うために POST メソッドが使用されています。一般的に、更新操作は PUT や PATCH メソッドを使用するのが適切ですが、フォームデータを含むリクエストを行うために POST メソッドを使用しました。


3.8 application.yml

spring:
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB

SpringFrameworkのWebアプリケーションでマルチパートフォームデータを処理する際に、アップロードされるファイルのサイズに関する制限を設定します。

4. テスト(08.24)


4.1 単体テスト

@DisplayName("addArticle: 記事登録に成功")
    @Test
    public void addArticle() throws Exception {
        // given
        final String url = "/api/articles";
        final String title = "title";
        final String content = "content";
        // 任意のバイト配列の作成
        byte[] dummyImageBytes = new byte[100];


        // イメージファイルテスト用のMultipartFile作成
        MockMultipartFile imageFile = new MockMultipartFile(
                "imageFiles",
                "test-image.jpg",
                "image/jpeg",
                dummyImageBytes); 

        MockMultipartHttpServletRequestBuilder multipartRequestBuilder = (MockMultipartHttpServletRequestBuilder) multipart(url)
                .file(imageFile)
                .param("title", title)
                .param("content", content);


        final AddArticleRequest userRequest = new AddArticleRequest(title, content, null);

        final String requestBody = objectMapper.writeValueAsString(userRequest);

        Principal principal = Mockito.mock(Principal.class);
        Mockito.when(principal.getName()).thenReturn("username");


        // when
        ResultActions result = mockMvc.perform(multipartRequestBuilder.principal(principal));


        // then
        result.andExpect(status().isCreated());

        List<Article> articles = blogRepository.findAll();

        assertThat(articles.size()).isEqualTo(1);
        assertThat(articles.get(0).getTitle()).isEqualTo(title);
        assertThat(articles.get(0).getContent()).isEqualTo(content);
    }


 

addArticle: 記事登録成功をテスト

MockMultipartHttpServletRequestBuilderを使用して、マルチパートフォームデータをシミュレートしています。

画像ファイルを添付したMultipartFileと記事のタイトル、コンテンツをリクエストパラメータとして設定します。

モックされたHTTPリクエストを作成して、コントローラーの記事登録エンドポイントを呼び出します。

レスポンスステータスが正常に作成されたことを期待します。

データベースから記事を取得し、アサーションを使用して正しい記事が登録されたことを確認します。

@DisplayName("deleteArticle: 記事削除成功")
    @Test
    public void deleteArticle() throws Exception {
        // given
        final String url = "/api/articles/{id}";
        Article savedArticle = createArticleWithImage(); // 글 생성과 함께 이미지 첨부파일을 가지는 글을 생성함

        // when
        mockMvc.perform(delete(url, savedArticle.getId()))
                .andExpect(status().isOk());

        // then
        List<Article> articles = blogRepository.findAll();
        List<ImageFile> imageFiles = imageRepository.findAll();

        assertThat(articles).isEmpty();
        // 添付ファイルも削除されたか検証 
        assertThat(imageFiles).isEmpty(); 
  }


private Article createArticleWithImage() {
        // イメージファイルテスト用のMultipartFile作成
        byte[] imageBytes = new byte[100];
        MockMultipartFile imageFile = new MockMultipartFile(
                "imageFiles",
                "test-image.jpg",
                "image/jpeg",
                imageBytes);

        // 記事作成および画像ファイル設定
        Article article = Article.builder()
                .title("title")
                .author(user.getUsername())
                .content("content")
                .build();

        ImageFile image = new ImageFile();
        image.setFilename(imageFile.getOriginalFilename());
        image.setArticle(article);

        article.setImageFiles(Collections.singletonList(image));

        return blogRepository.save(article);
    }

deleteArticle: 記事削除成功をテスト

テスト用の記事を作成し、その記事のIDを使用して記事削除エンドポイントを呼び出します。
レスポンスステータスが正常であることを期待します。
データベースから記事と関連する添付ファイルを削除したことを確認します。

 @DisplayName("updateArticle: 記事修正成功")
    @Test
    public void updateArticle() throws Exception {
        // given
        final String url = "/api/articles/{id}";
        Article savedArticle = createDefaultArticle();

        final String newTitle = "new title";
        final String newContent = "new content";
        byte[] newImageBytes = new byte[100]; 

        UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent, null);

        // イメージファイルテスト用のMultipartFile作成
        MockMultipartFile newImageFile = new MockMultipartFile(
                "newImageFiles",
                "test-new-image.jpg",
                "image/jpeg",
                newImageBytes);

        // when
        ResultActions result = mockMvc.perform(multipart(url, savedArticle.getId())
                .file(newImageFile)
                .param("title", newTitle)
                .param("content", newContent));

        // then
        result.andExpect(status().isOk());

        Article article = blogRepository.findById(savedArticle.getId()).get();

        assertThat(article.getTitle()).isEqualTo(newTitle);
        assertThat(article.getContent()).isEqualTo(newContent);
        
    }

updateArticle: 記事修正成功をテスト

テスト用の記事を作成し、修正後のタイトル、コンテンツ、画像を新しい情報としてリクエストパラメータに設定します。

モックされたHTTPリクエストを作成して、コントローラーの記事更新エンドポイントを呼び出します。

レスポンスステータスが正常であることを期待します。

データベースから記事を取得し、アサーションを使用して記事の情報が正しく更新されたことを確認します。

無事に単位テストの成功が完了しました。


4.2 結合テスト

ファイル選択ボタンを押します。
4 つのファイルを選択してみます。
3 つ以下のファイルのみを選択できるとメッセージを表示
3 つの画像ファイルを選択すると、
アップロードされる画像がプレビューされます。
登録された記事を確認します。
写真の左右にある矢印を押すと 写真が変わります。
記事の下部にある修正ボタンを押します。


プレビュー写真を2枚削除した後、
ファイル選択ボタンを押して、もう 1 つ追加します。


画像ファイルの 1 つが追加されたことを確認できます。
これから修正ボタンを押します。


修正が完了したというメッセージが表示されます。
画像ファイルが 2 つだけアップロードされた状態です。
/article_imgパスに画像ファイルがアップロードされました。
新しいファイル名は「ファイル名+UUID+ミリ秒時間+拡張子」
記事の下段の削除ボタンを押します。
記事の削除が完了しました。
アップロードされた画像が消えたことを確認しました。


5. 形状管理 - Git/GitHub


5.1 Git-Commit/ Github-Push

git add .
git commit -m 'feat : Add image upload'
Gitにコミットする。
git push origin main
Githubリモートリポジトリにプッシュします。
プッシュに成功したことを確認しました。


6. まとめ


やっと機能追加がおわりました。7 月 26 日から 8 月 24 日までの約 1 か月間、自分のWebアプリケーションに「フォロー」、「いいね」、「画像アップロード」の 3 つの機能を追加しました。 これまで、テキストだけを扱いながら何か足りないと思っていましたが、今回の開発経験をきっかけに、もう少し多様なタイプのデータを扱うことができるようになりました。 特に「Multipart」という転送方法に対する理解を深めることができ、物理ファイルの処理方法についてビジネスロジックを勉強することができてとても有益な時間でした!

これからまたどんな機能を開発するかは分かりませんが、この1ヶ月間の経験がとても強力に役に立つと思います。 今度新しい開発課題で会いましょう~!


エンジニアファーストの会社 株式会社CRE-CO

ソンさん


この記事が気に入ったらサポートをしてみませんか?