如何使用django-rest-framework的测试客户端测试二进制文件上传?

67

我有一个Django应用程序,其中一个视图接受要上传的文件。使用Django REST框架,我正在子类化APIView并实现以下方式的post()方法:

class FileUpload(APIView):
    permission_classes = (IsAuthenticated,)

    def post(self, request, *args, **kwargs):
        try:
            image = request.FILES['image']
            # Image processing here.
            return Response(status=status.HTTP_201_CREATED)
        except KeyError:
            return Response(status=status.HTTP_400_BAD_REQUEST, data={'detail' : 'Expected image.'})

现在我正在尝试编写一些单元测试,以确保需要身份验证,并且上传的文件实际被处理。

class TestFileUpload(APITestCase):
    def test_that_authentication_is_required(self):
        self.assertEqual(self.client.post('my_url').status_code, status.HTTP_401_UNAUTHORIZED)

    def test_file_is_accepted(self):
        self.client.force_authenticate(self.user)
        image = Image.new('RGB', (100, 100))
        tmp_file = tempfile.NamedTemporaryFile(suffix='.jpg')
        image.save(tmp_file)
        with open(tmp_file.name, 'rb') as data:
            response = self.client.post('my_url', {'image': data}, format='multipart')
            self.assertEqual(status.HTTP_201_CREATED, response.status_code)

但是当REST框架尝试对请求进行编码时,就会出现错误。
Traceback (most recent call last):
  File "/home/vagrant/.virtualenvs/myapp/lib/python3.3/site-packages/django/utils/encoding.py", line 104, in force_text
    s = six.text_type(s, encoding, errors)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 118: invalid start byte

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/vagrant/webapp/myproject/myapp/tests.py", line 31, in test_that_jpeg_image_is_accepted
    response = self.client.post('my_url', { 'image': data}, format='multipart')
  File "/home/vagrant/.virtualenvs/myapp/lib/python3.3/site-    packages/rest_framework/test.py", line 76, in post
    return self.generic('POST', path, data, content_type, **extra)
  File "/home/vagrant/.virtualenvs/myapp/lib/python3.3/site-packages/rest_framework/compat.py", line 470, in generic
    data = force_bytes_or_smart_bytes(data, settings.DEFAULT_CHARSET)
  File "/home/vagrant/.virtualenvs/myapp/lib/python3.3/site-packages/django/utils/encoding.py", line 73, in smart_text
    return force_text(s, encoding, strings_only, errors)
  File "/home/vagrant/.virtualenvs/myapp/lib/python3.3/site-packages/django/utils/encoding.py", line 116, in force_text
    raise DjangoUnicodeDecodeError(s, *e.args)
django.utils.encoding.DjangoUnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 118: invalid start byte. You passed in b'--BoUnDaRyStRiNg\r\nContent-Disposition: form-data; name="image"; filename="tmpyz2wac.jpg"\r\nContent-Type: image/jpeg\r\n\r\n\xff\xd8\xff[binary data omitted]' (<class 'bytes'>)

我该如何让测试客户端发送数据而不尝试将其解码为UTF-8?

2
传递{'image': file} - arocks
@arocks 眼睛真尖!我已经纠正了帖子中的拼写错误,实际代码并没有这个问题。 - Tore Olsen
你的代码对我有用。谢谢! - Khoi
我有同样的问题。你解决了吗? - Robin Elvin
4
是因为我缺少了 format='multipart' 参数 - 嗯 - Robin Elvin
临时文件从哪里来? - Mahammad Adil Azeem
5个回答

58

在测试文件上传时,你应该将流对象传递到请求中,而不是数据

这是由@arocks在评论中指出的。

应当传递 { 'image': file}

但这并没有充分解释为什么需要这样做(也与问题不完全相符)。对于这个特定的问题,你应该这样做:

from PIL import Image

class TestFileUpload(APITestCase):

    def test_file_is_accepted(self):
        self.client.force_authenticate(self.user)

        image = Image.new('RGB', (100, 100))

        tmp_file = tempfile.NamedTemporaryFile(suffix='.jpg')
        image.save(tmp_file)
        tmp_file.seek(0)

        response = self.client.post('my_url', {'image': tmp_file}, format='multipart')

       self.assertEqual(status.HTTP_201_CREATED, response.status_code)

这将匹配标准的Django请求,其中文件作为流对象传递,并由Django REST Framework处理。当您仅传递文件数据时,Django和Django REST Framework将其解释为字符串,这会导致问题,因为它期望一个流。

对于那些寻找另一个常见错误的人,即文件上传无法正常工作,但普通表单数据可以:确保在创建请求时设置format="multipart"

这也会导致类似的问题,并且在评论中由@RobinElvin指出:

这是因为我缺少了format='multipart'


15
由于某些原因,这导致出现了400错误。错误信息是{"file":["The submitted file is empty."]}。 - Divick
3
请注意,如果由于某种原因(例如性能),您不想分配临时文件,则还可以执行“tmp_file = BytesIO(b'some text')”; 这将为您提供一个可作为文件对象传递的二进制流。(https://docs.python.org/3/library/io.html) - Symmetric
5
在执行post之前,必须添加tmp_file.seek(0),但除此之外完美无缺!这几乎让我发疯了,所以谢谢! - jaywink
“Image” 模块从哪里来的? - user9903
1
@RudolfOlah Image 来自 Pillow 库。请参阅 https://pillow.readthedocs.io/en/stable/。 - Clinton Blackburn
重要的是要注意它是 format=multipart 而不是 content-type。如果有人也犯了同样的错误并得到了 415 错误,我会在这里留下这个提示。 - nck

22
Python 3 用户:确保您以 mode='rb'(读取,二进制)模式打开文件。否则,当 Django 调用文件的 read 方法时,utf-8 编解码器会立即开始阻塞。该文件应该被解码为二进制,而不是 utf-8、ascii 或任何其他编码。
# This won't work in Python 3
with open(tmp_file.name) as fp:
        response = self.client.post('my_url', 
                                   {'image': fp}, 
                                   format='multipart')

# Set the mode to binary and read so it can be decoded as binary
with open(tmp_file.name, 'rb') as fp:
        response = self.client.post('my_url', 
                                   {'image': fp}, 
                                   format='multipart')

4
我认为答案中的{'image': data} 应该改成 {'image': fp}。在找到这篇文章之前,我一直在苦苦挣扎上传文件,但是直到我用文件句柄对象fp来代替上述字典中的data时,我的测试才通过了。(在我的情况下,{'image': fp}可行,而{'image': data}不行。) - dmmfll
更新了。谢谢 DMfll。 - Meistro

14

你可以使用Django内置的SimpleUploadedFile

from django.core.files.uploadedfile import SimpleUploadedFile

class TestFileUpload(APITestCase):
    ...

    def test_file_is_accepted(self):
        ...

       tmp_file = SimpleUploadedFile(
                      "file.jpg", "file_content", content_type="image/jpg")

       response = self.client.post(
                      'my_url', {'image': tmp_file}, format='multipart')
       self.assertEqual(response.status_code, status.HTTP_201_CREATED)


6

如果您想使用PATCH方法,了解如何操作并不是那么简单,但我在这个问题中找到了解决方案。

from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart

with open(tmp_file.name, 'rb') as fp:
    response = self.client.patch(
        'my_url', 
        encode_multipart(BOUNDARY, {'image': fp}), 
        content_type=MULTIPART_CONTENT
    )

2

对于Windows用户来说,答案略有不同。我需要执行以下步骤:

resp = None
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp_file:
    image = Image.new('RGB', (100, 100), "#ddd")
    image.save(tmp_file, format="JPEG")
    tmp_file.close()

# create status update
with open(tmp_file.name, 'rb') as photo:
    resp = self.client.post('/api/articles/', {'title': 'title',
                                               'content': 'content',
                                               'photo': photo,
                                               }, format='multipart')
os.remove(tmp_file.name)

这篇文章讲述了在Windows系统下,文件被关闭后就无法继续使用,而在Linux系统下,@Meistro提供的方法可以解决这个问题。参考答案链接:https://dev59.com/ZWAg5IYBdhLWcg3wwtRp#23212515

在OSX10.12.5上,我遇到了“ValueError:I/O操作关闭的文件”的错误。 - joe
2
我认为您不需要在 with 语句中使用 tmp_file.close() - LondonAppDev

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接